From 04edf1d13289e23b4ebbf3bf0b6f0015d01393af Mon Sep 17 00:00:00 2001 From: Wilfried BARADAT Date: Tue, 12 Nov 2024 17:32:08 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=83=EF=B8=8F(mork)=20add=20user=20mode?= =?UTF-8?q?ls=20for=20multi-service=20deletion=20tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding models to give the list of user that needs to be deleted across multiple services (Ashley, EdX, Brevo, Joanie). The models enable: - Storing user information pending deletion - Tracking deletion status per service - Managing deletion requests with different reasons (GDPR, user-requested) --- src/app/mork/factories/users.py | 62 ++++++++++++ src/app/mork/migrations/env.py | 1 + .../b61049edae38_create_users_tables.py | 86 +++++++++++++++++ src/app/mork/models/users.py | 96 +++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 src/app/mork/factories/users.py create mode 100644 src/app/mork/migrations/versions/b61049edae38_create_users_tables.py create mode 100644 src/app/mork/models/users.py diff --git a/src/app/mork/factories/users.py b/src/app/mork/factories/users.py new file mode 100644 index 0000000..766d7b2 --- /dev/null +++ b/src/app/mork/factories/users.py @@ -0,0 +1,62 @@ +"""Factory classes for users.""" + +import factory + +from mork.models.users import ( + DeletionReason, + DeletionStatus, + ServiceName, + User, + UserServiceStatus, +) + + +class UserServiceStatusFactory(factory.alchemy.SQLAlchemyModelFactory): + """Factory for generating UserServiceStatus instances.""" + + class Meta: + """Factory configuration.""" + + model = UserServiceStatus + + user_id = factory.Sequence(lambda n: n + 1) + service_name = ServiceName + status = DeletionStatus.TO_DELETE + + +class UserFactory(factory.alchemy.SQLAlchemyModelFactory): + """Factory for generating fake users.""" + + class Meta: + """Factory configuration.""" + + model = User + + username = factory.Faker("pystr", max_chars=15) + edx_user_id = factory.Sequence(lambda n: n) + email = factory.Faker("email") + reason = DeletionReason.GDPR + + @factory.post_generation + def service_statuses( + self, + create: bool, + extracted: dict[ServiceName, DeletionStatus] | None, + **kwargs, + ): + """Post-generation hook to create UserServiceStatus for all services.""" + if not create: + return + + service_states = {service: DeletionStatus.TO_DELETE for service in ServiceName} + + if isinstance(extracted, dict): + service_states.update(extracted) + + for service, status in service_states.items(): + UserServiceStatusFactory( + user=self, + service_name=service, + status=status, + **kwargs, + ) diff --git a/src/app/mork/migrations/env.py b/src/app/mork/migrations/env.py index 9599ab6..7307598 100644 --- a/src/app/mork/migrations/env.py +++ b/src/app/mork/migrations/env.py @@ -10,6 +10,7 @@ # Nota bene: be sure to import all models that need to be migrated here from mork.models import Base from mork.models.tasks import EmailStatus +from mork.models.users import UserServiceStatus, User # this is the Alembic Config object, which provides # access to the values within the .ini file in use. diff --git a/src/app/mork/migrations/versions/b61049edae38_create_users_tables.py b/src/app/mork/migrations/versions/b61049edae38_create_users_tables.py new file mode 100644 index 0000000..7509fe0 --- /dev/null +++ b/src/app/mork/migrations/versions/b61049edae38_create_users_tables.py @@ -0,0 +1,86 @@ +"""Create users tables + +Revision ID: b61049edae38 +Revises: 976e462b45b6 +Create Date: 2024-11-08 10:38:01.575241 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "b61049edae38" +down_revision: Union[str, None] = "976e462b45b6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "users", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("username", sa.String(length=254), nullable=False), + sa.Column("edx_user_id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=254), nullable=False), + sa.Column( + "reason", + sa.Enum("USER_REQUESTED", "GDPR", name="deletion_reason"), + nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("edx_user_id"), + sa.UniqueConstraint("username"), + ) + op.create_table( + "user_service_statuses", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column( + "service_name", + sa.Enum("ASHLEY", "EDX", "BREVO", "JOANIE", name="service_name"), + nullable=False, + ), + sa.Column( + "status", + sa.Enum("TO_DELETE", "DELETING", "DELETED", name="deletion_status"), + nullable=False, + ), + sa.Column( + "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False + ), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "service_name", name="uq_record_service"), + ) + op.create_index( + "idx_service_status", + "user_service_statuses", + ["service_name", "status"], + unique=False, + ) + op.create_index( + "idx_user_service_status", + "user_service_statuses", + ["user_id", "service_name", "status"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index("idx_user_service_status", table_name="user_service_statuses") + op.drop_index("idx_service_status", table_name="user_service_statuses") + op.drop_table("user_service_statuses") + op.drop_table("users") + # ### end Alembic commands ### diff --git a/src/app/mork/models/users.py b/src/app/mork/models/users.py new file mode 100644 index 0000000..1dade02 --- /dev/null +++ b/src/app/mork/models/users.py @@ -0,0 +1,96 @@ +"""Mork users models.""" + +import enum +from typing import List +from uuid import uuid4 + +from sqlalchemy import ( + Enum, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from mork.models import Base + + +class ServiceName(str, enum.Enum): + """Enum for service names.""" + + ASHLEY = "ashley" + EDX = "edx" + BREVO = "brevo" + JOANIE = "joanie" + + +class DeletionReason(str, enum.Enum): + """Enum for the reason of the deletion request.""" + + USER_REQUESTED = "user_requested" + GDPR = "gdpr" + + +class DeletionStatus(str, enum.Enum): + """Enum for deletion statuses.""" + + TO_DELETE = "to_delete" + DELETED = "deleted" + + +class UserServiceStatus(Base): + """Table for storing the user status for a service.""" + + __tablename__ = "user_service_statuses" + __table_args__ = ( + UniqueConstraint("user_id", "service_name", name="uq_record_service"), + Index( + "idx_user_service_status", + "user_id", + "service_name", + "status", + ), + Index("idx_service_status", "service_name", "status"), + ) + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + service_name: Mapped[ServiceName] = mapped_column( + Enum(ServiceName, name="service_name"), nullable=False + ) + status: Mapped[DeletionStatus] = mapped_column( + Enum(DeletionStatus, name="deletion_status"), nullable=False, default=False + ) + + user: Mapped["User"] = relationship("User", back_populates="service_statuses") + + +class User(Base): + """Table for storing the users to delete.""" + + __tablename__ = "users" + + filtered_attrs = [] + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + username: Mapped[str] = mapped_column(String(254), unique=True, nullable=False) + edx_user_id: Mapped[int] = mapped_column(Integer(), unique=True) + email: Mapped[str] = mapped_column(String(254), nullable=False) + reason: Mapped[DeletionReason] = mapped_column( + Enum(DeletionReason, name="deletion_reason"), nullable=False + ) + + service_statuses: Mapped[List[UserServiceStatus]] = relationship( + "UserServiceStatus", + cascade="all, delete-orphan", + back_populates="user", + )