Skip to content

Commit bcda11a

Browse files
authored
Merge pull request #66 from ScilifelabDataCentre/organisation-in-user-model
SQ-805: Add organisation + user role to registration form
2 parents 6f0ba7b + 5a829ee commit bcda11a

27 files changed

Lines changed: 1001 additions & 100 deletions

docs/development/database-migrations.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,32 @@ You can also run the `pytest-alembic` tests to further validate the newly create
101101
pytest tests/migrations
102102
```
103103

104+
## Migrations in local development (swapping between branches/downgrading)
105+
106+
- If you pull a branch with new migrations, you will need to restart the stack to apply the new migrations (see above).
107+
- If you work on a branch with new migrations, and then switch back to main (or another branch without those migrations), you will need to downgrade the database to the previous migration version, and then restart the stack to apply the downgraded schema, here are the steps you can follows:
108+
109+
### Protocol
110+
111+
1. Be in the branch that has the latest migration script (as you need the downgrade function from the latest migration script so trying this from any other branch will not work).
112+
113+
2. Use the db-migrator container to run the downgrade command (so adjust the command/entry point in the docker compose file to something like this:
114+
115+
```bash
116+
# From: docker/divbase_compose.yaml command: ["alembic", "upgrade", "head"]
117+
["alembic", "downgrade", "<REVISION_ID_TO_DOWNGRADE_TO>"] # or -1 to downgrade one step
118+
```
119+
120+
3. Down everything
121+
122+
4. Undo the change to the db-migrator container in the docker compose file.
123+
124+
5. Swap to the other branch (e.g. main) that does not have the new migration script.
125+
126+
6. docker compose up/watch and it should just work as you will now be at "head" again.
127+
128+
The validation run by the fastapi lifespan event on startup will help tell you if you succeded or not, as it will error if you are not at the head revision for that branch. So if fastapi is working, it's probably working.
129+
104130
## Production Deployment
105131
106132
Documentation on how to run migrations in production/deployed environments is covered in our [private repository, argocd-divbase](https://github.com/ScilifelabDataCentre/argocd-divbase).
@@ -121,3 +147,27 @@ Documentation on how to run migrations in production/deployed environments is co
121147
### Starlette admin/admin panel not showing the models
122148
123149
- You need the models in your src to match the postgres schema. So if you have pending changes (that you have or have not created migrations for) they won't display until you've actually done the migration.
150+
151+
### Migrations that include new fields with non-nullable constraints
152+
153+
One way to solve this is to modify the migration scripts upgrade command as follows:
154+
155+
In this example, we're adding a new column "organisation" to the user table, and it cannot be null (and we already have users...), so we need to provide a default value for existing rows.
156+
157+
```python
158+
def upgrade() -> None:
159+
# Add the server_default param to the add operation for the new column
160+
# "server_default=sa.text("'Not specified'") was added to below command
161+
# We use sa.text to ensure the default value is set as a string in the database,
162+
# see e.g. here for why: https://github.com/sqlalchemy/alembic/discussions/1433
163+
op.add_column('user', sa.Column('organisation', sa.String(length=200), nullable=False, server_default=sa.text("'Not specified'"),))
164+
165+
# Any rows in the database table that existed before the migration will now be populated with the default value 'Not specified'.
166+
# At the end of the same migration script, remove the server_default param from the column (with `server_default=None`) - so our models match exactly with the database schema.
167+
# New rows will now require the application to provide a value for the organisation column.
168+
op.alter_column('user', 'organisation', server_default=None)
169+
```
170+
171+
**You don't need to set the server_default in the models.py, just in the migration script.**
172+
173+
Alternatively you could set the server_default in the models.py, but then you should be comfortable with it always having a default value. There are also other ways to solve this problem...

packages/divbase-api/src/divbase_api/admin_panel.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class UserView(ModelView):
8282
"id",
8383
StringField("name", required=True, help_text="Full name of the user."),
8484
EmailField("email", required=True, help_text="Email address of the user."),
85+
StringField("organisation", required=True, help_text="Organisation of the user."),
86+
StringField("organisation_role", required=True, help_text="Role of the user within their organisation."),
8587
StringField("password", required=True, help_text="Password for the user."),
8688
StringField(
8789
"hashed_password",
@@ -114,15 +116,32 @@ class UserView(ModelView):
114116
),
115117
]
116118

117-
exclude_fields_from_list = ["hashed_password", "password"]
119+
exclude_fields_from_list = [
120+
"hashed_password",
121+
"password",
122+
"organisation_role",
123+
"date_deleted",
124+
"last_password_change",
125+
"created_at",
126+
"updated_at",
127+
]
118128
exclude_fields_from_create = [
119129
"project_memberships",
120130
"is_deleted",
121131
"is_active",
122132
"last_password_change",
123133
"date_deleted",
124-
] # hashed_password wont be defined yet but needs to be included.
125-
exclude_fields_from_edit = ["project_memberships", "password", "hashed_password", "last_password_change"]
134+
"created_at",
135+
"updated_at",
136+
]
137+
exclude_fields_from_edit = [
138+
"project_memberships",
139+
"password",
140+
"hashed_password", # hashed_password wont be defined yet but needs to be included here.
141+
"last_password_change",
142+
"created_at",
143+
"updated_at",
144+
]
126145
exclude_fields_from_detail = ["hashed_password", "password"]
127146

128147
def can_delete(self, request: Request) -> bool:
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""
2+
DivBase API constants.
3+
"""
4+
5+
# List of Swedish universities for the dropdown in the registration/update profile form.
6+
SWEDISH_UNIVERSITIES = [
7+
"Blekinge Institute of Technology",
8+
"Chalmers University of Technology",
9+
"Dalarna University",
10+
"Göteborgs University",
11+
"Halmstad University",
12+
"Jönköping University",
13+
"KTH Royal Institute of Technology",
14+
"Karlstad University",
15+
"Karolinska institutet (KI)",
16+
"Kristianstad University",
17+
"Linköpings University",
18+
"Linnaeus University",
19+
"Luleå University of Technology",
20+
"Lund University",
21+
"Malmö University",
22+
"Mid Sweden University",
23+
"Mälardalen University",
24+
"Stockholm School of Economics",
25+
"Stockholms University",
26+
"Swedish Defence University",
27+
"Swedish School of Sport and Health Sciences",
28+
"Swedish University of Agricultural Sciences",
29+
"Södertörn University",
30+
"Umeå University",
31+
"University West",
32+
"University of Borås",
33+
"University of Gävle",
34+
"University of Skövde",
35+
"Uppsala University",
36+
"Örebro University",
37+
]
38+
39+
# List of known job roles for the dropdown in the registration/update profile form.
40+
KNOWN_JOB_ROLES = [
41+
"Bachelor's Student",
42+
"Bioinformatician",
43+
"Infrastructure Support Staff",
44+
"Master's Student",
45+
"PhD Candidate",
46+
"Postdoctoral Researcher",
47+
"Principal Investigator",
48+
"Research Associate",
49+
"Researcher",
50+
"Scientist",
51+
"Technician",
52+
]

packages/divbase-api/src/divbase_api/crud/auth.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ async def update_user_password(db: AsyncSession, user_id: int, password_data: Us
127127

128128
def delete_auth_cookies(response: Response) -> Response:
129129
"""Helper to delete auth cookies from a response (e.g. on logout)."""
130-
# TODO - when token blacklisting is implemented, blacklist the tokens here too. (make async at that point too...)
131130
response.delete_cookie(TokenType.ACCESS.value)
132131
response.delete_cookie(TokenType.REFRESH.value)
133132
return response

packages/divbase-api/src/divbase_api/crud/users.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async def create_user(
5858
internal_logging_message=f"Attempt made to register new account with existing email: {proposed_email}"
5959
)
6060

61-
user_dict = user_data.model_dump(exclude={"password"})
61+
user_dict = user_data.model_dump(exclude={"password", "confirm_password"})
6262
hashed_password = get_password_hash(user_data.password)
6363

6464
user = UserDB(**user_dict, hashed_password=hashed_password, is_admin=is_admin, email_verified=email_verified)
@@ -119,3 +119,18 @@ async def update_user_profile(db: AsyncSession, user_data: UserUpdate, user_id:
119119
await db.commit()
120120
await db.refresh(user)
121121
return user
122+
123+
124+
def resolve_dropdown_form_input(dropdown_value: str, other_value: str | None) -> str | None:
125+
"""
126+
Helper to resolve "Other" fields in registration/profile update forms.
127+
These fields have a dropdown with predefined options and an "Other" option that reveals a text input.
128+
If "Other" selected, we take the value from the text input instead. If the text input is empty or too short, we return None to indicate an error (e.g. in the registration/profile update endpoint), otherwise we return the resolved value. If "Other" is not selected, we just return the original value.
129+
130+
If non-resolvable, returns None, so calling function can return an error response.
131+
"""
132+
if dropdown_value != "Other":
133+
return dropdown_value.strip()
134+
if not other_value or len(other_value.strip()) < 3:
135+
return None
136+
return other_value.strip()

packages/divbase-api/src/divbase_api/db.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ async def create_first_admin_user() -> None:
110110
name="First Admin",
111111
email=admin_email,
112112
password=admin_password,
113+
confirm_password=admin_password,
114+
organisation="DivBase University",
115+
organisation_role="Developer",
113116
)
114117

115118
admin_user = await create_user(

packages/divbase-api/src/divbase_api/exception_handlers.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,21 @@ async def global_exception_handler(request: Request, exc: Exception):
8383
Handle unexpected exceptions globally. - in the ideal world this is never be triggered
8484
"""
8585
logger.error(f"Unexpected Error occurred for: {request.method} {request.url.path}: {exc}", exc_info=True)
86-
return JSONResponse(
87-
status_code=500,
88-
content={
89-
"detail": "An unexpected error occurred. Please try again later.",
90-
"type": "server_error",
91-
},
92-
)
86+
87+
if is_api_request(request):
88+
return JSONResponse(
89+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
90+
content={
91+
"detail": "An unexpected error occurred. Please try again later.",
92+
"type": "server_error",
93+
},
94+
)
95+
else:
96+
return await render_error_page(
97+
request,
98+
"An unexpected error occurred. Please try again later.",
99+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
100+
)
93101

94102

95103
async def authentication_error_handler(request: Request, exc: AuthenticationError):

packages/divbase-api/src/divbase_api/frontend_routes/auth.py

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sqlalchemy.ext.asyncio import AsyncSession
1111

1212
from divbase_api.api_config import settings
13+
from divbase_api.api_constants import KNOWN_JOB_ROLES, SWEDISH_UNIVERSITIES
1314
from divbase_api.crud.auth import (
1415
authenticate_user,
1516
check_user_email_verified,
@@ -18,7 +19,7 @@
1819
update_user_password,
1920
)
2021
from divbase_api.crud.revoked_tokens import revoke_token_on_logout, revoke_used_password_reset_token, token_is_revoked
21-
from divbase_api.crud.users import create_user, get_user_by_email, get_user_by_id_or_raise
22+
from divbase_api.crud.users import create_user, get_user_by_email, get_user_by_id_or_raise, resolve_dropdown_form_input
2223
from divbase_api.db import get_db
2324
from divbase_api.deps import get_current_user_from_cookie_optional
2425
from divbase_api.exceptions import AuthenticationError
@@ -128,7 +129,15 @@ async def get_register(request: Request, current_user: UserDB | None = Depends(g
128129
"""Render the registration page."""
129130
if current_user:
130131
return RedirectResponse(url="/", status_code=status.HTTP_302_FOUND)
131-
return templates.TemplateResponse(request=request, name="auth_pages/register.html", context={"current_user": None})
132+
return templates.TemplateResponse(
133+
request=request,
134+
name="auth_pages/register.html",
135+
context={
136+
"current_user": None,
137+
"swedish_universities": SWEDISH_UNIVERSITIES,
138+
"known_job_roles": KNOWN_JOB_ROLES,
139+
},
140+
)
132141

133142

134143
@fr_auth_router.post("/register", response_class=HTMLResponse)
@@ -137,6 +146,10 @@ async def post_register(
137146
background_tasks: BackgroundTasks,
138147
name: str = Form(...),
139148
email: str = Form(...),
149+
organisation: str = Form(...),
150+
organisation_other: str | None = Form(None),
151+
role: str = Form(...),
152+
role_other: str | None = Form(None),
140153
password: str = Form(...),
141154
confirm_password: str = Form(...),
142155
db: AsyncSession = Depends(get_db),
@@ -148,25 +161,56 @@ def registration_failed_response(error_message: str):
148161
return templates.TemplateResponse(
149162
request=request,
150163
name="auth_pages/register.html",
151-
context={"error": error_message, "name": name, "email": email},
164+
context={
165+
"error": error_message,
166+
"name": name,
167+
"email": email,
168+
"organisation": organisation,
169+
"organisation_other": organisation_other,
170+
"role": role,
171+
"role_other": role_other,
172+
"swedish_universities": SWEDISH_UNIVERSITIES,
173+
"known_job_roles": KNOWN_JOB_ROLES,
174+
},
152175
)
153176

154-
if password != confirm_password:
155-
return registration_failed_response("Passwords do not match")
177+
resolved_organisation = resolve_dropdown_form_input(dropdown_value=organisation, other_value=organisation_other)
178+
if not resolved_organisation:
179+
return registration_failed_response("Please specify your organisation, it must be at least 3 characters long.")
180+
181+
resolved_role = resolve_dropdown_form_input(dropdown_value=role, other_value=role_other)
182+
if not resolved_role:
183+
return registration_failed_response("Please specify your role, it must be at least 3 characters long.")
156184

157-
existing_user = await get_user_by_email(db=db, email=email)
185+
existing_user = await get_user_by_email(db=db, email=email.strip())
158186
if existing_user: # Not recommended to specify why failed, just say failed.
187+
logger.warning(f"Attempt to register new account with already registered email: {email}")
159188
return registration_failed_response("Registration failed, please try again.")
160189

161190
try:
162-
user_data = UserCreate(name=name, email=email, password=SecretStr(password))
191+
user_data = UserCreate(
192+
name=name.strip(),
193+
email=email.strip(),
194+
organisation=resolved_organisation.strip(),
195+
organisation_role=resolved_role.strip(),
196+
confirm_password=SecretStr(confirm_password),
197+
password=SecretStr(password),
198+
)
163199
user = await create_user(db=db, user_data=user_data, is_admin=False)
200+
except ValidationError as e:
201+
error_msg = " ".join(err["msg"] for err in e.errors())
202+
# We warn about this because the frontend should have prevented it.
203+
# so if we get validation errors here it could be because:
204+
# Some mismatch in backend vs frontend validation logic or
205+
# someone bypassing client side validation (could be accidently or intentionally).
206+
# Don't think it is a good idea to return the validation error messages to the user as it could leak info.
207+
logger.warning(f"User registration failed backend validation for email: {email} - {error_msg}")
208+
return registration_failed_response("Registration failed, please try again.")
164209
except Exception as e:
165-
logger.error(f"Error creating user: {e}")
210+
logger.error(f"Unexpected error during user registration for email: {email} - {str(e)}")
166211
return registration_failed_response("Registration failed, please try again.")
167212

168213
background_tasks.add_task(send_verification_email, email_to=user.email, user_id=user.id)
169-
170214
logger.info(f"New user registered: {user_data.email=}")
171215
return templates.TemplateResponse(
172216
request=request,

0 commit comments

Comments
 (0)