From d6051d9c99f2272bb4238e7986044d095474319e Mon Sep 17 00:00:00 2001 From: stuppie Date: Fri, 16 Oct 2026 11:59:18 -0700 Subject: gr django changes: add user email, change privileges, uniq membership user team --- ...er_email_alter_membership_privilege_and_more.py | 27 ++++++++++++++++++++++ generalresearch/gr_django/models.py | 13 +++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py diff --git a/generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py b/generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py new file mode 100644 index 0000000..c072b1a --- /dev/null +++ b/generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 6.1.1 on 2026-10-16 18:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0002_business_balance_business_balance_net_7d_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, null=True, unique=True), + ), + migrations.AlterField( + model_name='membership', + name='privilege', + field=models.PositiveSmallIntegerField(choices=[(0, 'Admin'), (1, 'Member')], default=1), + ), + migrations.AddConstraint( + model_name='membership', + constraint=models.UniqueConstraint(fields=('user_id', 'team_id'), name='uniq_membership_user_team'), + ), + ] diff --git a/generalresearch/gr_django/models.py b/generalresearch/gr_django/models.py index 01bf749..8b2d91d 100644 --- a/generalresearch/gr_django/models.py +++ b/generalresearch/gr_django/models.py @@ -6,6 +6,7 @@ from django.db import models class User(models.Model): id = models.BigAutoField(primary_key=True) sub = models.CharField(max_length=200, blank=False, null=False) + email = models.EmailField(null=True, unique=True) is_superuser = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) @@ -37,14 +38,22 @@ class Membership(models.Model): PRIVILEGES = ( (0, "Admin"), - (1, "Maintain"), - (2, "Read"), + (1, "Member"), ) privilege = models.PositiveSmallIntegerField(default=1, choices=PRIVILEGES) + + # todo: delete this field once managers are not querying for it owner = models.BooleanField(default=False) created = models.DateTimeField(auto_now_add=True, null=False) + class Meta: + constraints = [ + models.UniqueConstraint( + fields=['user_id', 'team_id'], + name='uniq_membership_user_team' + ) + ] class Team(models.Model): """ -- cgit v1.2.3 From 4f66f3d89188e9ddaf59ba7cd104deddde516a6f Mon Sep 17 00:00:00 2001 From: stuppie Date: Fri, 16 Oct 2026 12:00:58 -0700 Subject: gr: a lot of changes to managers (membership priveleges, checks for team membershipness, add/remove business) --- generalresearch/managers/gr/authentication.py | 98 ++++++++++---------- generalresearch/managers/gr/team.py | 128 +++++++++++++------------- generalresearch/models/gr/__init__.py | 3 +- generalresearch/models/gr/authentication.py | 2 + generalresearch/models/gr/team.py | 15 +-- 5 files changed, 128 insertions(+), 118 deletions(-) diff --git a/generalresearch/managers/gr/authentication.py b/generalresearch/managers/gr/authentication.py index f1ac2de..18c8ba4 100644 --- a/generalresearch/managers/gr/authentication.py +++ b/generalresearch/managers/gr/authentication.py @@ -26,6 +26,7 @@ class GRUserManager(PostgresManagerWithRedis): def create( self, sub: str, + email: str | None = None, is_superuser: bool = False, ) -> GRUser: from generalresearch.models.gr.authentication import GRUser @@ -35,6 +36,7 @@ class GRUserManager(PostgresManagerWithRedis): instance = GRUser.model_validate( { "sub": sub, + "email": email, "is_superuser": is_superuser, "date_joined": now, } @@ -45,8 +47,8 @@ class GRUserManager(PostgresManagerWithRedis): with conn.cursor() as c: query = sql.SQL(""" INSERT INTO gr_user - (sub, is_superuser, date_joined) - VALUES (%(sub)s, %(is_superuser)s, %(date_joined)s) + (sub, email, is_superuser, date_joined) + VALUES (%(sub)s, %(email)s, %(is_superuser)s, %(date_joined)s) RETURNING id """) c.execute(query=query, params=data) @@ -56,63 +58,61 @@ class GRUserManager(PostgresManagerWithRedis): instance.id = gr_user_id return instance - def get_by_id(self, gr_user_id: int) -> GRUser | None: + def filter( + self, + *, + gr_user_id: int | None = None, + sub: str | None = None, + email: str | None = None, + ) -> list[GRUser]: from generalresearch.models.gr.authentication import GRUser + conditions = [] + params = {} + + if gr_user_id is not None: + conditions.append("u.id = %(gr_user_id)s") + params['gr_user_id'] = gr_user_id + if sub is not None: + conditions.append("u.sub = %(sub)s") + params['sub'] = sub + if email is not None: + conditions.append("u.email = %(email)s") + params['email'] = email + + filter_str = " AND ".join(conditions) + filter_str = "WHERE " + filter_str if filter_str else "" + + query = f""" + SELECT u.* + FROM gr_user AS u + {filter_str} + """ with self.pg_config.make_connection() as conn, conn.cursor() as c: - c.execute( - query=""" - SELECT u.* - FROM gr_user AS u - WHERE u.id = %s - LIMIT 1; - """, - params=(gr_user_id,), - ) - res = c.fetchone() + c.execute(query=query, params=params) + res = c.fetchall() - if res is None: - raise ValueError("GRUser not found") - assert isinstance( - res, dict - ), "GRUserManager.get_by_id query returned invalid results" + return [GRUser.from_postgresql(item) for item in res] - # We can return None if no MySQL results were found... but raise an - # error if returning failed for a different reason - gr_user = GRUser.from_postgresql(res) - assert isinstance(gr_user, GRUser), "GRUser not serialized correctly" - return gr_user + def _get_one(self, *, raises: bool = True, **filters: int | str) -> GRUser | None: + users = self.filter(**filters) - def get_by_sub(self, sub: str, raises=True) -> GRUser | None: - from generalresearch.models.gr.authentication import GRUser - - with self.pg_config.make_connection() as conn, conn.cursor() as c: - c.execute( - query=""" - SELECT u.* - FROM gr_user AS u - WHERE u.sub = %s - LIMIT 1; - """, - params=(sub,), - ) - res = c.fetchone() + if not users: + if raises: + raise ValueError("GRUser not found") + return None - if raises and res is None: - raise ValueError("GRUser not found") + assert len(users) == 1, "GRUserManager filter returned multiple users" + return users[0] - if res is None: - return None + def get_by_id(self, gr_user_id: int) -> GRUser | None: + return self._get_one(gr_user_id=gr_user_id) - assert isinstance( - res, dict - ), "GRUserManager.get_by_id query returned invalid results" + def get_by_sub(self, sub: str, raises=True) -> GRUser | None: + return self._get_one(sub=sub, raises=raises) - # We can return None if no MySQL results were found... but raise an - # error if returning failed for a different reason - gr_user = GRUser.from_postgresql(res) - assert isinstance(gr_user, GRUser), "GRUser not serialized correctly" - return gr_user + def get_by_email(self, email: str, raises=True) -> GRUser | None: + return self._get_one(email=email, raises=raises) def get_by_sub_or_create(self, sub: str) -> GRUser: return self.get_by_sub(sub=sub, raises=False) or self.create(sub=sub) diff --git a/generalresearch/managers/gr/team.py b/generalresearch/managers/gr/team.py index 5d9c6bb..405435e 100644 --- a/generalresearch/managers/gr/team.py +++ b/generalresearch/managers/gr/team.py @@ -11,7 +11,6 @@ from generalresearch.managers.base import ( PostgresManager, PostgresManagerWithRedis, ) -from generalresearch.managers.gr.authentication import GRUserManager from generalresearch.models.custom_types import UUIDStr from generalresearch.models.gr.team import ( Membership, @@ -37,12 +36,11 @@ class MembershipManager(PostgresManager): self, team: Team, gr_user: GRUser, - privilege: MembershipPrivilege = MembershipPrivilege.READ, + privilege: MembershipPrivilege = MembershipPrivilege.MEMBER, ) -> Membership: membership = Membership( uuid=uuid4().hex, - privilege=MembershipPrivilege.READ, - owner=False, + privilege=privilege, team_id=team.id, user_id=gr_user.id, created=datetime.now(tz=UTC), @@ -70,8 +68,8 @@ class MembershipManager(PostgresManager): c.execute( query=sql.SQL(""" INSERT INTO common_membership - (uuid, privilege, owner, team_id, user_id, created) - VALUES (%(uuid)s, %(privilege)s, %(owner)s, %(team_id)s, + (uuid, privilege, team_id, user_id, created) + VALUES (%(uuid)s, %(privilege)s, %(team_id)s, %(user_id)s, %(created)s) RETURNING id """), @@ -86,55 +84,55 @@ class MembershipManager(PostgresManager): def exists( self, gr_user_id: PositiveInt, team_id: PositiveInt ) -> Membership | None: - with self.pg_config.make_connection() as conn, conn.cursor() as c: - c.execute( - query=sql.SQL(""" - SELECT id, uuid, privilege, owner, created, - user_id, team_id - FROM common_membership - WHERE team_id = %s AND user_id = %s - LIMIT 1 - """), - params=(team_id, gr_user_id), - ) - res = c.fetchone() - - if not res: - return None - - return Membership.model_validate(res) + memberships = self.filter(gr_user_id=gr_user_id, team_id=team_id) + if len(memberships) > 1: + raise ValueError("User has multiple memberships in the same Team") + return memberships[0] if memberships else None def get_by_team_id(self, team_id: PositiveInt) -> list[Membership]: - with self.pg_config.make_connection() as conn, conn.cursor() as c: - c.execute( - query=sql.SQL(""" - SELECT id, uuid, privilege, owner, created, - user_id, team_id - FROM common_membership - WHERE team_id = %s - LIMIT 250 - """), - params=(team_id,), - ) - res = c.fetchall() - - return [Membership.model_validate(i) for i in res] + return self.filter(team_id=team_id) def get_by_gr_user_id(self, gr_user_id: PositiveInt) -> list[Membership]: + return self.filter(gr_user_id=gr_user_id) + + def filter( + self, + *, + gr_user_id: PositiveInt | None = None, + team_id: PositiveInt | None = None, + ) -> list[Membership]: + """Return memberships matching all the supplied identifiers.""" + conditions = [] + params = {} + + if gr_user_id is not None: + conditions.append("m.user_id = %(user_id)s") + params["user_id"] = gr_user_id + if team_id is not None: + conditions.append("m.team_id = %(team_id)s") + params["team_id"] = team_id + + filter_str = " AND ".join(conditions) + filter_str = "WHERE " + filter_str if filter_str else "" + + query = f""" + SELECT + m.id, + m.uuid, + m.privilege, + m.created, + m.user_id, + m.team_id + FROM common_membership AS m + {filter_str} + LIMIT 250 + """ + with self.pg_config.make_connection() as conn, conn.cursor() as c: - c.execute( - query=sql.SQL(""" - SELECT id, uuid, privilege, owner, created, - user_id, team_id - FROM common_membership - WHERE user_id = %s - LIMIT 250 - """), - params=(gr_user_id,), - ) + c.execute(query=query, params=params) res = c.fetchall() - return [Membership.model_validate(i) for i in res] + return [Membership.model_validate(item) for item in res] class TeamManager(PostgresManagerWithRedis): @@ -217,20 +215,6 @@ class TeamManager(PostgresManagerWithRedis): self.redis_client.delete(team.cache_key) return team - def add_user( - self, team: Team, gr_user: GRUser, gr_user_manager: GRUserManager - ) -> Membership: - """Create a Membership between a GRUser and a Team""" - - team.prefetch_gr_users(gr_user_manager=gr_user_manager) - - assert gr_user not in team.gr_users, ( - "Can't create multiple Memberships for the same User to the same Team" - ) - mm = MembershipManager(pg_config=self.pg_config) - - return mm.create(team=team, gr_user=gr_user) - def add_business( self, team_id: PositiveInt, @@ -251,6 +235,26 @@ class TeamManager(PostgresManagerWithRedis): ) conn.commit() + def remove_business( + self, + team_id: PositiveInt, + business_id: PositiveInt, + conn: Connection | None = None, + ) -> None: + with self.connection(conn) as conn, conn.cursor() as c: + c.execute( + query=sql.SQL(""" + DELETE FROM common_team_businesses + WHERE team_id = %s and business_id = %s + """), + params=( + team_id, + business_id, + ), + ) + assert c.rowcount == 1, "Team Business Association not found" + conn.commit() + def get_by_uuid(self, team_uuid: UUIDStr) -> Team | None: from generalresearch.models.gr.team import Team diff --git a/generalresearch/models/gr/__init__.py b/generalresearch/models/gr/__init__.py index 132f72b..c6d9b2a 100644 --- a/generalresearch/models/gr/__init__.py +++ b/generalresearch/models/gr/__init__.py @@ -1,6 +1,6 @@ from generalresearch.models.gr.authentication import GRToken, GRUser from generalresearch.models.gr.business import Business, BusinessCreate -from generalresearch.models.gr.team import Team +from generalresearch.models.gr.team import Team, TeamCreate from generalresearch.models.thl.finance import BusinessBalances from generalresearch.models.thl.payout import BrokerageProductPayoutEvent from generalresearch.models.thl.product import Product @@ -12,4 +12,5 @@ GRUser.model_rebuild() GRToken.model_rebuild() BusinessCreate.model_rebuild() Business.model_rebuild() +TeamCreate.model_rebuild() Team.model_rebuild() diff --git a/generalresearch/models/gr/authentication.py b/generalresearch/models/gr/authentication.py index fa0bcae..123e1dc 100644 --- a/generalresearch/models/gr/authentication.py +++ b/generalresearch/models/gr/authentication.py @@ -14,6 +14,7 @@ from pydantic import ( NonNegativeInt, PositiveInt, field_validator, + EmailStr, ) from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr @@ -106,6 +107,7 @@ class GRUser(BaseModel): id: PositiveInt | None = Field(default=None) sub: str | None = Field(max_length=200) + email: EmailStr | None = Field(default=None) is_superuser: bool = Field(default=False) date_joined: AwareDatetimeISO = Field( diff --git a/generalresearch/models/gr/team.py b/generalresearch/models/gr/team.py index b53b913..d340c68 100644 --- a/generalresearch/models/gr/team.py +++ b/generalresearch/models/gr/team.py @@ -53,9 +53,14 @@ if TYPE_CHECKING: class MembershipPrivilege(Enum, metaclass=ReprEnumMeta): + """ + This determines permissions regarding team membership management + """ + + # An admin can invite other users to a team ADMIN = 0 - MAINTAIN = 1 - READ = 2 + # A member is not + MEMBER = 1 class Membership(BaseModel): @@ -74,13 +79,11 @@ class Membership(BaseModel): uuid: UUIDStrCoerce = Field(examples=[uuid4().hex]) privilege: MembershipPrivilege = Field( - default=MembershipPrivilege.MAINTAIN, - examples=[MembershipPrivilege.READ.value], + default=MembershipPrivilege.ADMIN, + examples=[MembershipPrivilege.MEMBER.value], description=MembershipPrivilege.as_openapi(), ) - owner: bool = Field(default=False, examples=[True]) - created: AwareDatetimeISO = Field( description="This is when the User was added to the Team, it's when" "the Membership was created and not when the GR User " -- cgit v1.2.3 From af1bdc63baffa818e7831877f7f3f66cfdbcbd7c Mon Sep 17 00:00:00 2001 From: stuppie Date: Fri, 16 Oct 2026 12:23:29 -0700 Subject: django: membership owner nullable so we can remove it later --- ...3_user_email_alter_membership_owner_and_more.py | 32 ++++++++++++++++++++++ ...er_email_alter_membership_privilege_and_more.py | 27 ------------------ generalresearch/gr_django/models.py | 2 +- 3 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 generalresearch/gr_django/migrations/0003_user_email_alter_membership_owner_and_more.py delete mode 100644 generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py diff --git a/generalresearch/gr_django/migrations/0003_user_email_alter_membership_owner_and_more.py b/generalresearch/gr_django/migrations/0003_user_email_alter_membership_owner_and_more.py new file mode 100644 index 0000000..a36979f --- /dev/null +++ b/generalresearch/gr_django/migrations/0003_user_email_alter_membership_owner_and_more.py @@ -0,0 +1,32 @@ +# Generated by Django 6.1.1 on 2026-10-16 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0002_business_balance_business_balance_net_7d_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='email', + field=models.EmailField(max_length=254, null=True, unique=True), + ), + migrations.AlterField( + model_name='membership', + name='owner', + field=models.BooleanField(default=False, null=True), + ), + migrations.AlterField( + model_name='membership', + name='privilege', + field=models.PositiveSmallIntegerField(choices=[(0, 'Admin'), (1, 'Member')], default=1), + ), + migrations.AddConstraint( + model_name='membership', + constraint=models.UniqueConstraint(fields=('user_id', 'team_id'), name='uniq_membership_user_team'), + ), + ] diff --git a/generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py b/generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py deleted file mode 100644 index c072b1a..0000000 --- a/generalresearch/gr_django/migrations/0003_user_email_alter_membership_privilege_and_more.py +++ /dev/null @@ -1,27 +0,0 @@ -# Generated by Django 6.1.1 on 2026-10-16 18:56 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('common', '0002_business_balance_business_balance_net_7d_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='email', - field=models.EmailField(max_length=254, null=True, unique=True), - ), - migrations.AlterField( - model_name='membership', - name='privilege', - field=models.PositiveSmallIntegerField(choices=[(0, 'Admin'), (1, 'Member')], default=1), - ), - migrations.AddConstraint( - model_name='membership', - constraint=models.UniqueConstraint(fields=('user_id', 'team_id'), name='uniq_membership_user_team'), - ), - ] diff --git a/generalresearch/gr_django/models.py b/generalresearch/gr_django/models.py index 8b2d91d..7f26eaf 100644 --- a/generalresearch/gr_django/models.py +++ b/generalresearch/gr_django/models.py @@ -43,7 +43,7 @@ class Membership(models.Model): privilege = models.PositiveSmallIntegerField(default=1, choices=PRIVILEGES) # todo: delete this field once managers are not querying for it - owner = models.BooleanField(default=False) + owner = models.BooleanField(default=False, null=True) created = models.DateTimeField(auto_now_add=True, null=False) -- cgit v1.2.3 From 2bf3341db9a815a4e697bea333478406ee4c32f8 Mon Sep 17 00:00:00 2001 From: stuppie Date: Fri, 16 Oct 2026 12:33:23 -0700 Subject: MembershipManager: update and delete membership. Membership model remove use_enum_values; it confuses the type checker. On db write explicitly pass the int value --- generalresearch/managers/gr/team.py | 45 +++++++++++++++++++++++++++++++++++++ generalresearch/models/gr/team.py | 2 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/generalresearch/managers/gr/team.py b/generalresearch/managers/gr/team.py index 405435e..d673dfc 100644 --- a/generalresearch/managers/gr/team.py +++ b/generalresearch/managers/gr/team.py @@ -49,6 +49,7 @@ class MembershipManager(PostgresManager): data = membership.model_dump(by_alias=True) data["team_id"] = team.id data["user_id"] = gr_user.id + data["privilege"] = membership.privilege.value # 'user_id' = {int} 5774 # 'team_id' = {int} 20736 @@ -95,11 +96,18 @@ class MembershipManager(PostgresManager): def get_by_gr_user_id(self, gr_user_id: PositiveInt) -> list[Membership]: return self.filter(gr_user_id=gr_user_id) + def get_by_uuid(self, membership_uuid: UUIDStr) -> Membership | None: + memberships = self.filter(membership_uuid=membership_uuid) + if len(memberships) > 1: + raise ValueError("Multiple memberships have the same UUID") + return memberships[0] if memberships else None + def filter( self, *, gr_user_id: PositiveInt | None = None, team_id: PositiveInt | None = None, + membership_uuid: UUIDStr | None = None, ) -> list[Membership]: """Return memberships matching all the supplied identifiers.""" conditions = [] @@ -111,6 +119,9 @@ class MembershipManager(PostgresManager): if team_id is not None: conditions.append("m.team_id = %(team_id)s") params["team_id"] = team_id + if membership_uuid is not None: + conditions.append("m.uuid = %(membership_uuid)s") + params["membership_uuid"] = membership_uuid filter_str = " AND ".join(conditions) filter_str = "WHERE " + filter_str if filter_str else "" @@ -134,6 +145,40 @@ class MembershipManager(PostgresManager): return [Membership.model_validate(item) for item in res] + def update_privilege( + self, + membership_id: PositiveInt, + privilege: MembershipPrivilege, + ) -> Membership: + with self.pg_config.make_connection() as conn, conn.cursor() as cursor: + cursor.execute( + query=""" + UPDATE common_membership + SET privilege = %s + WHERE id = %s + RETURNING uuid + """, + params=(privilege.value, membership_id), + ) + row = cursor.fetchone() + if row is None: + raise LookupError(f"Membership {membership_id} does not exist") + conn.commit() + + membership = self.get_by_uuid(membership_uuid=row["uuid"]) + assert membership is not None + return membership + + def delete(self, membership_id: PositiveInt) -> None: + with self.pg_config.make_connection() as conn, conn.cursor() as cursor: + cursor.execute( + query="DELETE FROM common_membership WHERE id = %s", + params=(membership_id,), + ) + if cursor.rowcount != 1: + raise LookupError(f"Membership {membership_id} does not exist") + conn.commit() + class TeamManager(PostgresManagerWithRedis): def get_or_create( diff --git a/generalresearch/models/gr/team.py b/generalresearch/models/gr/team.py index d340c68..c96a68e 100644 --- a/generalresearch/models/gr/team.py +++ b/generalresearch/models/gr/team.py @@ -71,7 +71,7 @@ class Membership(BaseModel): a Team can have various levels of permissions and rights. """ - model_config = ConfigDict(use_enum_values=True) + model_config = ConfigDict() id: SkipJsonSchema[PositiveInt | None] = Field( default=None, -- cgit v1.2.3 From fa55c7880c6969ed7274b5f28b2e23811aa2627b Mon Sep 17 00:00:00 2001 From: stuppie Date: Fri, 16 Oct 2026 13:08:01 -0700 Subject: gr user email in fixtures --- test_utils/models/gr/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test_utils/models/gr/conftest.py b/test_utils/models/gr/conftest.py index f5dcaa1..d99bd89 100644 --- a/test_utils/models/gr/conftest.py +++ b/test_utils/models/gr/conftest.py @@ -217,6 +217,7 @@ def gr_user_factory(gr_user_manager: GRUserManager) -> Callable[..., GRUser]: def _inner( save: bool = True, sub: str | None = None, + email: str | None = None, is_superuser: bool = False, ) -> GRUser: sub = sub or f"{uuid4().hex}-{uuid4().hex}" @@ -224,6 +225,7 @@ def gr_user_factory(gr_user_manager: GRUserManager) -> Callable[..., GRUser]: if save: return gr_user_manager.create( sub=sub, + email=email, is_superuser=is_superuser, ) else: -- cgit v1.2.3 From 2f833952ac09610e31465db979aa515127727b17 Mon Sep 17 00:00:00 2001 From: stuppie Date: Fri, 16 Oct 2026 13:30:23 -0700 Subject: bump --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 15d9118..c165cd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "generalresearch" -version = "3.8.6" +version = "3.8.7" description = "Python Utilities for General Research" readme = "README.md" requires-python = ">=3.14" -- cgit v1.2.3 From 6329677471c4c393fc3f182924a23e3884542342 Mon Sep 17 00:00:00 2001 From: stuppie Date: Fri, 16 Oct 2026 15:19:11 -0700 Subject: create GRUserRef and add to Membership so a user's email comes back within the membership obj --- generalresearch/managers/gr/team.py | 11 +++++++++++ generalresearch/models/gr/__init__.py | 4 ++-- generalresearch/models/gr/authentication.py | 15 ++++++++++++++- generalresearch/models/gr/team.py | 8 +++++--- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/generalresearch/managers/gr/team.py b/generalresearch/managers/gr/team.py index d673dfc..89a3a19 100644 --- a/generalresearch/managers/gr/team.py +++ b/generalresearch/managers/gr/team.py @@ -43,6 +43,7 @@ class MembershipManager(PostgresManager): privilege=privilege, team_id=team.id, user_id=gr_user.id, + user=gr_user.to_user_ref(), created=datetime.now(tz=UTC), ) @@ -133,8 +134,12 @@ class MembershipManager(PostgresManager): m.privilege, m.created, m.user_id, + u.email AS user_email, + u.date_joined AS user_date_joined, m.team_id FROM common_membership AS m + JOIN gr_user AS u + ON u.id = m.user_id {filter_str} LIMIT 250 """ @@ -143,6 +148,12 @@ class MembershipManager(PostgresManager): c.execute(query=query, params=params) res = c.fetchall() + for item in res: + item["user"] = { + "email": item.pop("user_email"), + "date_joined": item.pop("user_date_joined"), + } + return [Membership.model_validate(item) for item in res] def update_privilege( diff --git a/generalresearch/models/gr/__init__.py b/generalresearch/models/gr/__init__.py index c6d9b2a..fb27718 100644 --- a/generalresearch/models/gr/__init__.py +++ b/generalresearch/models/gr/__init__.py @@ -1,4 +1,4 @@ -from generalresearch.models.gr.authentication import GRToken, GRUser +from generalresearch.models.gr.authentication import GRToken, GRUser, GRUserRef from generalresearch.models.gr.business import Business, BusinessCreate from generalresearch.models.gr.team import Team, TeamCreate from generalresearch.models.thl.finance import BusinessBalances @@ -6,7 +6,7 @@ from generalresearch.models.thl.payout import BrokerageProductPayoutEvent from generalresearch.models.thl.product import Product # To prevent type checkers from removing these imports from this file -_ = Product, BrokerageProductPayoutEvent, BusinessBalances +_ = Product, BrokerageProductPayoutEvent, BusinessBalances, GRUserRef GRUser.model_rebuild() GRToken.model_rebuild() diff --git a/generalresearch/models/gr/authentication.py b/generalresearch/models/gr/authentication.py index 123e1dc..5366db8 100644 --- a/generalresearch/models/gr/authentication.py +++ b/generalresearch/models/gr/authentication.py @@ -10,11 +10,11 @@ from pydantic import ( AnyHttpUrl, BaseModel, ConfigDict, + EmailStr, Field, NonNegativeInt, PositiveInt, field_validator, - EmailStr, ) from generalresearch.models.custom_types import AwareDatetimeISO, UUIDStr @@ -98,6 +98,13 @@ class Claims(BaseModel): return self.sub +class GRUserRef(BaseModel): + email: EmailStr | None = Field(default=None) + date_joined: AwareDatetimeISO = Field( + description="When the GR User account signed up." + ) + + class GRUser(BaseModel): model_config = ConfigDict( # extra="forbid", @@ -121,6 +128,12 @@ class GRUser(BaseModel): token: GRToken | None = Field(default=None) claims: Claims | None = Field(default=None) + def to_user_ref(self) -> GRUserRef: + return GRUserRef( + email=self.email, + date_joined=self.date_joined, + ) + def prefetch_claims( self, token: str, key: dict[str, Any], audience: str, issuer: AnyHttpUrl ) -> None: diff --git a/generalresearch/models/gr/team.py b/generalresearch/models/gr/team.py index c96a68e..ee379eb 100644 --- a/generalresearch/models/gr/team.py +++ b/generalresearch/models/gr/team.py @@ -29,6 +29,7 @@ from generalresearch.models.custom_types import ( UUIDStr, UUIDStrCoerce, ) +from generalresearch.models.gr.authentication import GRUserRef from generalresearch.utils.enum import ReprEnumMeta if TYPE_CHECKING: @@ -73,7 +74,7 @@ class Membership(BaseModel): model_config = ConfigDict() - id: SkipJsonSchema[PositiveInt | None] = Field( + id: PositiveInt | None = Field( default=None, ) uuid: UUIDStrCoerce = Field(examples=[uuid4().hex]) @@ -90,9 +91,10 @@ class Membership(BaseModel): "account was created." ) - user_id: SkipJsonSchema[PositiveInt] = Field() + user_id: PositiveInt = Field() + user: GRUserRef = Field() - team_id: SkipJsonSchema[PositiveInt] = Field() + team_id: PositiveInt = Field() # prefetch attributes team: SkipJsonSchema[Team | None] = Field(default=None) -- cgit v1.2.3