diff --git a/server/mergin/auth/commands.py b/server/mergin/auth/commands.py index f9ac636e..af6ed6af 100644 --- a/server/mergin/auth/commands.py +++ b/server/mergin/auth/commands.py @@ -8,7 +8,7 @@ from sqlalchemy import or_, func from ..app import db -from .models import User, UserProfile +from .models import User from ..commands import normalize_input @@ -36,7 +36,6 @@ def create(username, password, is_admin, email): # pylint: disable=W0612 sys.exit(1) user = User(username=username, passwd=password, is_admin=is_admin, email=email) - user.profile = UserProfile() user.active = True db.session.add(user) db.session.commit() diff --git a/server/mergin/auth/controller.py b/server/mergin/auth/controller.py index a4d584c8..06859255 100644 --- a/server/mergin/auth/controller.py +++ b/server/mergin/auth/controller.py @@ -24,7 +24,7 @@ CANNOT_EDIT_PROFILE_MSG, ) from .bearer import encode_token -from .models import User, LoginHistory, UserProfile +from .models import User, LoginHistory from .schemas import UserSchema, UserSearchSchema, UserProfileSchema, UserInfoSchema from .forms import ( LoginForm, @@ -65,7 +65,7 @@ def user_profile(user, return_all=True): { "email": user.email, "storage_limit": data["storage"], # duplicate - we should remove it - "receive_notifications": user.profile.receive_notifications, + "receive_notifications": user.receive_notifications, "verified_email": user.verified_email, "tier": "free", "registration_date": user.registration_date, @@ -369,7 +369,6 @@ def update_user_profile(): # pylint: disable=W0613,W0612 return jsonify(form.errors), 400 current_user.verified_email = False - form.update_obj(current_user.profile) form.update_obj(current_user) db.session.add(current_user) db.session.commit() @@ -483,7 +482,7 @@ def get_paginated_users( :rtype: Dict[str: List[User], str: Integer] """ - users = User.query.join(UserProfile).filter( + users = User.query.filter( is_(User.username.ilike("deleted_%"), False) | is_(User.active, True) ) @@ -491,14 +490,16 @@ def get_paginated_users( users = users.filter( User.username.ilike(f"%{like}%") | User.email.ilike(f"%{like}%") - | UserProfile.first_name.ilike(f"%{like}%") - | UserProfile.last_name.ilike(f"%{like}%") + | User.first_name.ilike(f"%{like}%") + | User.last_name.ilike(f"%{like}%") ) if descending and order_by: users = users.order_by(desc(User.__table__.c[order_by])) elif not descending and order_by: users = users.order_by(asc(User.__table__.c[order_by])) + else: + users = users.order_by(asc(User.id)) paginate = users.paginate(page=page, per_page=per_page) result = paginate.items @@ -561,7 +562,7 @@ def create_user(): workspace_role=request.json["role"], ) - if user.profile.receive_notifications: + if user.receive_notifications: send_confirmation_email( current_app, user, diff --git a/server/mergin/auth/models.py b/server/mergin/auth/models.py index 470b934b..760ab740 100644 --- a/server/mergin/auth/models.py +++ b/server/mergin/auth/models.py @@ -19,12 +19,9 @@ class User(db.Model): id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(80), info={"label": "Username"}) email = db.Column(db.String(120)) - passwd = db.Column(db.String(80), info={"label": "Password"}) # salted + hashed - active = db.Column(db.Boolean, default=True) is_admin = db.Column(db.Boolean) verified_email = db.Column(db.Boolean, default=False) @@ -35,8 +32,12 @@ class User(db.Model): info={"label": "Date of creation of user account"}, default=datetime.datetime.utcnow, ) - last_signed_in = db.Column(db.DateTime(), nullable=True) + receive_notifications = db.Column( + db.Boolean, default=True, nullable=False, index=True + ) + first_name = db.Column(db.String(256), nullable=True) + last_name = db.Column(db.String(256), nullable=True) __table_args__ = ( db.Index("ix_user_username", func.lower(username), unique=True), @@ -187,8 +188,8 @@ def anonymize(self): self.username = del_str self.email = None self.passwd = None - self.profile.first_name = None - self.profile.last_name = None + self.first_name = None + self.last_name = None db.session.commit() @classmethod @@ -240,11 +241,19 @@ def create( cls, username: str, email: str, password: str, notifications: bool = True ) -> User: user = cls(username.strip(), email.strip(), password, False) - user.profile = UserProfile(receive_notifications=notifications) + user.receive_notifications = notifications db.session.add(user) db.session.commit() return user + @property + def profile(self) -> "User": + """Compatibility shim: profile fields are now on User directly.""" + return self + + def name(self) -> Optional[str]: + return f'{self.first_name if self.first_name else ""} {self.last_name if self.last_name else ""}'.strip() + @property def can_edit_profile(self) -> bool: """Flag if we allow user to edit their email and name""" @@ -252,26 +261,6 @@ def can_edit_profile(self) -> bool: return self.passwd is not None and self.active -class UserProfile(db.Model): - user_id = db.Column( - db.Integer, db.ForeignKey("user.id", ondelete="CASCADE"), primary_key=True - ) - receive_notifications = db.Column(db.Boolean, default=True, index=True) - first_name = db.Column(db.String(256), nullable=True, info={"label": "First name"}) - last_name = db.Column(db.String(256), nullable=True, info={"label": "Last name"}) - - user = db.relationship( - "User", - uselist=False, - backref=db.backref( - "profile", single_parent=True, uselist=False, cascade="all,delete" - ), - ) - - def name(self) -> Optional[str]: - return f'{self.first_name if self.first_name else ""} {self.last_name if self.last_name else ""}'.strip() - - class LoginHistory(db.Model): id = db.Column(db.Integer, primary_key=True) timestamp = db.Column(db.DateTime(), default=datetime.datetime.utcnow, index=True) diff --git a/server/mergin/auth/schemas.py b/server/mergin/auth/schemas.py index 52ed01f6..0bb45c3e 100644 --- a/server/mergin/auth/schemas.py +++ b/server/mergin/auth/schemas.py @@ -5,7 +5,7 @@ from flask import current_app from marshmallow import fields -from .models import User, UserProfile +from .models import User from ..app import DateTimeWithZ, ma @@ -20,13 +20,13 @@ class UserProfileSchema(ma.SQLAlchemyAutoSchema): def get_storage(self, obj): # DEPRECATED functionality - kept for the backward-compatibility - ws = current_app.ws_handler.get_by_name(obj.user.username) + ws = current_app.ws_handler.get_by_name(obj.username) if ws: return ws.storage def get_disk_usage(self, obj): # DEPRECATED functionality - kept for the backward-compatibility - ws = current_app.ws_handler.get_by_name(obj.user.username) + ws = current_app.ws_handler.get_by_name(obj.username) if ws: return ws.disk_usage() @@ -34,21 +34,30 @@ def _has_project(self, obj): # DEPRECATED functionality - kept for the backward-compatibility from ..sync.models import ProjectUser, Project - ws = current_app.ws_handler.get_by_name(obj.user.username) + ws = current_app.ws_handler.get_by_name(obj.username) if ws: projects_count = ( Project.query.join(ProjectUser) - .filter(Project.creator_id == obj.user.id) + .filter(Project.creator_id == obj.id) .filter(Project.removed_at.is_(None)) .filter(Project.workspace_id == ws.id) - .filter(ProjectUser.user_id == obj.user.id) + .filter(ProjectUser.user_id == obj.id) .count() ) return projects_count > 0 return False class Meta: - model = UserProfile + model = User + fields = ( + "receive_notifications", + "first_name", + "last_name", + "name", + "storage", + "disk_usage", + "has_project", + ) load_instance = True @@ -81,7 +90,7 @@ class UserSearchSchema(ma.SQLAlchemyAutoSchema): name = fields.Method("_name", dump_only=True) def _name(self, obj): - return obj.profile.name() + return obj.name() class Meta: model = User @@ -97,11 +106,11 @@ class Meta: class UserInfoSchema(ma.SQLAlchemyAutoSchema): """User schema with full information""" - first_name = fields.String(attribute="profile.first_name") - last_name = fields.String(attribute="profile.last_name") - receive_notifications = fields.Boolean(attribute="profile.receive_notifications") + first_name = fields.String() + last_name = fields.String() + receive_notifications = fields.Boolean() registration_date = DateTimeWithZ(attribute="registration_date") - name = fields.Function(lambda obj: obj.profile.name()) + name = fields.Function(lambda obj: obj.name()) can_edit_profile = fields.Boolean(attribute="can_edit_profile") class Meta: diff --git a/server/mergin/commands.py b/server/mergin/commands.py index dbde7b7f..464890bf 100644 --- a/server/mergin/commands.py +++ b/server/mergin/commands.py @@ -202,7 +202,7 @@ def init_db(): ) def init(email: str, recreate: bool): """Initialize database if does not exist or -r is provided. Perform check of server configuration. Send statistics, respecting your setup.""" - from .auth.models import User, UserProfile + from .auth.models import User inspect_engine = inspect(db.engine) tables = inspect_engine.get_table_names() @@ -221,7 +221,6 @@ def init(email: str, recreate: bool): password_chars = string.ascii_letters + string.digits password = "".join(random.choice(password_chars) for i in range(12)) user = User(username=username, passwd=password, email=email, is_admin=True) - user.profile = UserProfile() user.active = True db.session.add(user) db.session.commit() diff --git a/server/mergin/sync/project_handler.py b/server/mergin/sync/project_handler.py index 7949dc20..e4d7e189 100644 --- a/server/mergin/sync/project_handler.py +++ b/server/mergin/sync/project_handler.py @@ -3,7 +3,7 @@ from .permissions import ProjectPermissions from sqlalchemy import or_, and_ from typing import List -from ..auth.models import User, UserProfile +from ..auth.models import User class ProjectHandler(AbstractProjectHandler): @@ -12,8 +12,7 @@ def get_push_permission(self, changes: dict): def get_email_receivers(self, project: Project) -> List[User]: return ( - User.query.join(UserProfile) - .outerjoin(ProjectUser, ProjectUser.user_id == User.id) + User.query.outerjoin(ProjectUser, ProjectUser.user_id == User.id) .filter( or_( and_( @@ -24,7 +23,7 @@ def get_email_receivers(self, project: Project) -> List[User]: ), User.active, User.verified_email, - UserProfile.receive_notifications, + User.receive_notifications, ) .all() ) diff --git a/server/mergin/tests/test_auth.py b/server/mergin/tests/test_auth.py index b130c109..ba7730c3 100644 --- a/server/mergin/tests/test_auth.py +++ b/server/mergin/tests/test_auth.py @@ -14,7 +14,7 @@ from ..auth.bearer import decode_token, encode_token from ..auth.forms import ResetPasswordForm from ..auth.app import generate_confirmation_token, confirm_token -from ..auth.models import User, UserProfile, LoginHistory +from ..auth.models import User, LoginHistory from ..auth.tasks import anonymize_removed_users from ..app import db from ..sync.models import Project, ProjectRole @@ -286,7 +286,6 @@ def test_change_password(client): email="user_test@mergin.com", ) user.active = True - user.profile = UserProfile() db.session.add(user) db.session.commit() diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py index 25e0e055..d1981391 100644 --- a/server/mergin/tests/test_project_controller.py +++ b/server/mergin/tests/test_project_controller.py @@ -41,7 +41,7 @@ from ..sync.files import files_changes_from_upload from ..sync.schemas import ProjectListSchema from ..sync.utils import Checkpoint, generate_checksum, is_versioned_file -from ..auth.models import User, UserProfile +from ..auth.models import User from . import ( test_project, @@ -666,7 +666,6 @@ def test_update_project(client): username="tester", passwd="tester", is_admin=False, email="tester@mergin.com" ) test_user.active = True - test_user.profile = UserProfile() db.session.add(test_user) db.session.commit() diff --git a/server/mergin/tests/utils.py b/server/mergin/tests/utils.py index 89ead403..57f67e80 100644 --- a/server/mergin/tests/utils.py +++ b/server/mergin/tests/utils.py @@ -15,7 +15,7 @@ from dateutil.tz import tzlocal from pygeodiff import GeoDiff -from ..auth.models import User, UserProfile +from ..auth.models import User from ..sync.utils import generate_location, generate_checksum from ..sync.models import ( Project, @@ -52,7 +52,6 @@ def add_user(username="random", password="random", is_admin=False) -> User: ) user.active = True user.verified_email = True - user.profile = UserProfile() db.session.add(user) db.session.commit() return user diff --git a/server/migrations/community/e3f1a9b2c4d6_merge_user_profile_into_user.py b/server/migrations/community/e3f1a9b2c4d6_merge_user_profile_into_user.py new file mode 100644 index 00000000..88898460 --- /dev/null +++ b/server/migrations/community/e3f1a9b2c4d6_merge_user_profile_into_user.py @@ -0,0 +1,82 @@ +# Copyright (C) Lutra Consulting Limited +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial + +"""Merge user_profile table into user table + +Revision ID: e3f1a9b2c4d6 +Revises: 4b4648483770 +Create Date: 2026-04-14 00:00:00.000000 +""" +from alembic import op +import sqlalchemy as sa + +revision = "e3f1a9b2c4d6" +down_revision = "4b4648483770" +branch_labels = None +depends_on = None + + +def upgrade(): + # Add profile columns to user table (nullable initially to allow data copy) + op.add_column( + "user", sa.Column("receive_notifications", sa.Boolean(), nullable=True) + ) + op.add_column("user", sa.Column("first_name", sa.String(256), nullable=True)) + op.add_column("user", sa.Column("last_name", sa.String(256), nullable=True)) + + # Copy data from user_profile + op.execute( + """ + UPDATE "user" u + SET + receive_notifications = up.receive_notifications, + first_name = up.first_name, + last_name = up.last_name + FROM user_profile up + WHERE up.user_id = u.id; + """ + ) + + # Fill in default for any users without a profile row (should not exist, but be safe) + op.execute( + 'UPDATE "user" SET receive_notifications = FALSE WHERE receive_notifications IS NULL;' + ) + + op.alter_column("user", "receive_notifications", nullable=False) + op.create_index("ix_user_receive_notifications", "user", ["receive_notifications"]) + op.drop_table("user_profile") + + +def downgrade(): + # Recreate user_profile table + op.create_table( + "user_profile", + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("receive_notifications", sa.Boolean(), nullable=True), + sa.Column("first_name", sa.String(256), nullable=True), + sa.Column("last_name", sa.String(256), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("user_id"), + ) + + # Copy data back + op.execute( + """ + INSERT INTO user_profile (user_id, receive_notifications, first_name, last_name) + SELECT id, receive_notifications, first_name, last_name + FROM "user"; + """ + ) + + op.create_index( + "ix_user_profile_receive_notifications", + "user_profile", + ["receive_notifications"], + ) + + # Remove columns from user table + op.drop_index("ix_user_receive_notifications", table_name="user") + op.drop_column("user", "receive_notifications") + op.drop_column("user", "first_name") + op.drop_column("user", "last_name")