diff --git a/alembic/versions/2db361ce1d3d_add_color_for_linked_calendar.py b/alembic/versions/2db361ce1d3d_add_color_for_linked_calendar.py new file mode 100644 index 0000000..40a7807 --- /dev/null +++ b/alembic/versions/2db361ce1d3d_add_color_for_linked_calendar.py @@ -0,0 +1,31 @@ +"""Add color for linked calendar + +Revision ID: 2db361ce1d3d +Revises: a9f09ae0208d +Create Date: 2023-10-16 23:07:37.504254 + +""" +# ruff: noqa: E501 +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "2db361ce1d3d" +down_revision: Union[str, None] = "a9f09ae0208d" +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.add_column("linked_calendars", sa.Column("color", sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("linked_calendars", "color") + # ### end Alembic commands ### diff --git a/alembic/versions/85bf4b0af84d_add_linked_calendars.py b/alembic/versions/85bf4b0af84d_add_linked_calendars.py new file mode 100644 index 0000000..37b8330 --- /dev/null +++ b/alembic/versions/85bf4b0af84d_add_linked_calendars.py @@ -0,0 +1,100 @@ +"""Add linked calendars + +Revision ID: 85bf4b0af84d +Revises: 0f714fc8defc +Create Date: 2023-10-16 22:34:45.234130 + +""" +# ruff: noqa: E501 +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "85bf4b0af84d" +down_revision: Union[str, None] = "0f714fc8defc" +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( + "linked_calendars", + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("alias", sa.String(length=255), nullable=False), + sa.Column("url", sa.String(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.drop_constraint("workshops_x_ownerships_object_id_fkey", "workshops_x_ownerships") + op.drop_table("workshop_checkins") + op.drop_table("timeslots") + op.drop_table("workshops") + op.drop_table("workshops_x_ownerships") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "workshops_x_ownerships", + sa.Column("object_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("role_alias", sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(["object_id"], ["workshops.id"], name="workshops_x_ownerships_object_id_fkey"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="workshops_x_ownerships_user_id_fkey"), + sa.PrimaryKeyConstraint("object_id", "user_id", name="workshops_x_ownerships_pkey"), + ) + op.create_table( + "workshops", + sa.Column("alias", sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column("date", sa.DATE(), autoincrement=False, nullable=False), + sa.Column("speaker", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column("capacity", sa.INTEGER(), autoincrement=False, nullable=True), + sa.Column("comment", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column("location", sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column( + "id", + sa.INTEGER(), + server_default=sa.text("nextval('workshops_id_seq'::regclass)"), + autoincrement=True, + nullable=False, + ), + sa.Column("name", sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint("id", name="workshops_pkey"), + sa.UniqueConstraint("alias", name="workshops_alias_key"), + postgresql_ignore_search_path=False, + ) + op.create_table( + "timeslots", + sa.Column("workshop_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("sequence", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("start", postgresql.TIME(), autoincrement=False, nullable=False), + sa.Column("end", postgresql.TIME(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(["workshop_id"], ["workshops.id"], name="timeslots_workshop_id_fkey"), + sa.PrimaryKeyConstraint("sequence", name="timeslots_pkey"), + ) + op.create_table( + "workshop_checkins", + sa.Column("workshop_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("user_id", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column("timeslot_sequence", sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column( + "dtstamp", postgresql.TIMESTAMP(), server_default=sa.text("now()"), autoincrement=False, nullable=False + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="workshop_checkins_user_id_fkey"), + sa.ForeignKeyConstraint(["workshop_id"], ["workshops.id"], name="workshop_checkins_workshop_id_fkey"), + sa.PrimaryKeyConstraint("workshop_id", "user_id", "timeslot_sequence", name="workshop_checkins_pkey"), + ) + op.drop_table("linked_calendars") + # ### end Alembic commands ### diff --git a/alembic/versions/a9f09ae0208d_add_constraint_for_linked_calendars.py b/alembic/versions/a9f09ae0208d_add_constraint_for_linked_calendars.py new file mode 100644 index 0000000..6783343 --- /dev/null +++ b/alembic/versions/a9f09ae0208d_add_constraint_for_linked_calendars.py @@ -0,0 +1,34 @@ +"""Add constraint for linked calendars + +Revision ID: a9f09ae0208d +Revises: 85bf4b0af84d +Create Date: 2023-10-16 22:53:08.602036 + +""" +# ruff: noqa: E501 +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "a9f09ae0208d" +down_revision: Union[str, None] = "85bf4b0af84d" +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_unique_constraint("unique_user_id_alias", "linked_calendars", ["user_id", "alias"]) + op.drop_constraint("tags_alias_type_key", "tags", type_="unique") + op.create_unique_constraint("unique_alias", "tags", ["alias", "type"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint("unique_alias", "tags", type_="unique") + op.create_unique_constraint("tags_alias_type_key", "tags", ["alias", "type"]) + op.drop_constraint("unique_user_id_alias", "linked_calendars", type_="unique") + # ### end Alembic commands ### diff --git a/src/app/users/routes.py b/src/app/users/routes.py index 89e34b5..8e5c8cd 100644 --- a/src/app/users/routes.py +++ b/src/app/users/routes.py @@ -1,3 +1,6 @@ +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError + from src.app.dependencies import ( EVENT_GROUP_REPOSITORY_DEPENDENCY, USER_REPOSITORY_DEPENDENCY, @@ -12,6 +15,7 @@ EventGroupNotFoundException, ) from src.schemas import ViewUser +from src.schemas.linked import LinkedCalendarView, LinkedCalendarCreate @router.get( @@ -105,3 +109,26 @@ async def hide_favorite( updated_user = await user_repository.set_hidden(user_id=user_id, group_id=group_id, hide=hide) updated_user: ViewUser return updated_user + + +@router.post( + "/me/linked", + responses={ + 200: {"description": "Linked calendar added successfully"}, + **IncorrectCredentialsException.responses, + **NoCredentialsException.responses, + }, +) +async def link_calendar( + linked_calendar: LinkedCalendarCreate, + user_id: CURRENT_USER_ID_DEPENDENCY, + user_repository: USER_REPOSITORY_DEPENDENCY, +) -> LinkedCalendarView: + """ + Add linked calendar to current user + """ + try: + calendar = await user_repository.link_calendar(user_id, linked_calendar) + return calendar + except IntegrityError: + raise HTTPException(status_code=409, detail="Calendar with this alias already exists") diff --git a/src/repositories/users/abc.py b/src/repositories/users/abc.py index 4f5a410..db8c8cb 100644 --- a/src/repositories/users/abc.py +++ b/src/repositories/users/abc.py @@ -3,8 +3,9 @@ from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING + if TYPE_CHECKING: - from src.schemas import CreateUser, ViewUser + from src.schemas import CreateUser, ViewUser, LinkedCalendarCreate, LinkedCalendarView class AbstractUserRepository(metaclass=ABCMeta): @@ -46,3 +47,7 @@ async def remove_favorite(self, user_id: int, favorite_id: int) -> "ViewUser": @abstractmethod async def set_hidden(self, user_id: int, group_id: int, hide: bool = True) -> "ViewUser": ... + + @abstractmethod + async def link_calendar(self, user_id: int, calendar: "LinkedCalendarCreate") -> "LinkedCalendarView": + ... diff --git a/src/repositories/users/repository.py b/src/repositories/users/repository.py index 4343ec7..851bc72 100644 --- a/src/repositories/users/repository.py +++ b/src/repositories/users/repository.py @@ -9,10 +9,12 @@ from sqlalchemy.sql.expression import exists from src.exceptions import DBEventGroupDoesNotExistInDb -from src.repositories.crud import CRUDFactory +from src.repositories.crud import CRUDFactory, AbstractCRUDRepository from src.repositories.users.abc import AbstractUserRepository +from src.schemas import LinkedCalendarView +from src.schemas.linked import LinkedCalendarCreate from src.schemas.users import CreateUser, ViewUser, UpdateUser -from src.storages.sql.models import User, EventGroup, UserXFavoriteEventGroup +from src.storages.sql.models import User, EventGroup, UserXFavoriteEventGroup, LinkedCalendar from src.storages.sql.storage import AbstractSQLAlchemyStorage @@ -26,7 +28,17 @@ def SELECT_USER_BY_ID(id_: int): ) -CRUD = CRUDFactory(User, CreateUser, ViewUser, UpdateUser, get_options=(selectinload(User.favorites_association),)) +_get_options = ( + selectinload(User.favorites_association), + selectinload(User.linked_calendars), +) + + +CRUD: AbstractCRUDRepository[ + CreateUser, + ViewUser, + UpdateUser, +] = CRUDFactory(User, CreateUser, ViewUser, UpdateUser, get_options=_get_options) MIN_USER_ID = 100_000 MAX_USER_ID = 999_999 @@ -71,9 +83,7 @@ async def batch_create_or_read(self, users: list[CreateUser]) -> list[ViewUser]: q = ( q.on_conflict_do_update(index_elements=[User.email], set_={"id": User.id}) .returning(User) - .options( - selectinload(User.favorites_association), - ) + .options(*_get_options) ) db_users = await session.scalars(q) await session.commit() @@ -86,9 +96,7 @@ async def create_or_update(self, user: CreateUser) -> ViewUser: q = ( q.on_conflict_do_update(index_elements=[User.email], set_={**q.excluded, "id": User.id}) .returning(User) - .options( - selectinload(User.favorites_association), - ) + .options(*_get_options) ) user = await session.scalar(q) await session.commit() @@ -165,13 +173,22 @@ async def set_hidden(self, user_id: int, group_id: int, hide: bool = True) -> "V event_group.hidden = hide # from table + q = select(User).where(User.id == user_id).options(*_get_options) + user = await session.scalar(q) + await session.commit() + return ViewUser.from_orm(user) + + async def link_calendar(self, user_id: int, calendar: "LinkedCalendarCreate") -> "LinkedCalendarView": + async with self._create_session() as session: q = ( - select(User) - .where(User.id == user_id) - .options( - selectinload(User.favorites_association), + insert(LinkedCalendar) + .values( + user_id=user_id, + **calendar.dict(), ) + .returning(LinkedCalendar) ) - user = await session.scalar(q) + + calendar = await session.scalar(q) await session.commit() - return ViewUser.from_orm(user) + return LinkedCalendarView.from_orm(calendar) diff --git a/src/schemas/__init__.py b/src/schemas/__init__.py index d2b8163..1f08b62 100644 --- a/src/schemas/__init__.py +++ b/src/schemas/__init__.py @@ -11,6 +11,7 @@ "Ownership", "OwnershipEnum", # TODO: Implement worshops # "CreateWorkshop", "ViewWorkshop", "CheckIn", "Timeslot", "CreateTimeslot", + "LinkedCalendarCreate", "LinkedCalendarView", "LinkedCalendarUpdate" ] # fmt: on @@ -24,6 +25,7 @@ ) from src.schemas.events import CreateEvent, ViewEvent, UpdateEvent, AddEventPatch, ViewEventPatch, UpdateEventPatch from src.schemas.users import CreateUser, ViewUser, UpdateUser +from src.schemas.linked import LinkedCalendarCreate, LinkedCalendarView, LinkedCalendarUpdate from src.schemas.tags import CreateTag, ViewTag, UpdateTag from src.schemas.ownership import Ownership, OwnershipEnum diff --git a/src/schemas/linked.py b/src/schemas/linked.py new file mode 100644 index 0000000..44b811b --- /dev/null +++ b/src/schemas/linked.py @@ -0,0 +1,51 @@ +__all__ = ["LinkedCalendarView", "LinkedCalendarCreate", "LinkedCalendarUpdate"] + + +from typing import Optional + + +from pydantic import BaseModel + + +class LinkedCalendarView(BaseModel): + """ + Represents a linked calendar instance from the database excluding sensitive information. + """ + + id: int + user_id: int + alias: str + url: str + name: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + is_active: bool = True + + class Config: + orm_mode = True + + +class LinkedCalendarCreate(BaseModel): + """ + Represents a linked calendar instance to be created. + """ + + alias: str + url: str + name: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + is_active: bool = True + + +class LinkedCalendarUpdate(BaseModel): + """ + Represents a linked calendar instance to be updated. + """ + + alias: Optional[str] = None + url: Optional[str] = None + name: Optional[str] = None + description: Optional[str] = None + color: Optional[str] = None + is_active: Optional[bool] = None diff --git a/src/schemas/users.py b/src/schemas/users.py index 2d0f982..ec05e38 100644 --- a/src/schemas/users.py +++ b/src/schemas/users.py @@ -4,6 +4,8 @@ from pydantic import Field, BaseModel, validator +from src.schemas.linked import LinkedCalendarView + class CreateUser(BaseModel): """ @@ -24,6 +26,7 @@ class ViewUser(BaseModel): email: str name: Optional[str] = None favorites_association: list["UserXFavoriteGroupView"] = Field(default_factory=list) + linked_calendars: dict[str, "LinkedCalendarView"] = Field(default_factory=dict) @validator("favorites_association", pre=True) def groups_to_list(cls, v): @@ -31,6 +34,13 @@ def groups_to_list(cls, v): v = list(v) return v + @validator("linked_calendars", pre=True) + def calendars_to_dict(cls, v): + if not isinstance(v, dict): + keys = [calendar.alias for calendar in v] + return dict(zip(keys, v)) + return v + class Config: orm_mode = True diff --git a/src/storages/sql/models/__init__.py b/src/storages/sql/models/__init__.py index e60f84b..833e517 100644 --- a/src/storages/sql/models/__init__.py +++ b/src/storages/sql/models/__init__.py @@ -6,7 +6,9 @@ from src.storages.sql.models.events import Event, EventPatch from src.storages.sql.models.tags import Tag from src.storages.sql.models.users import User -from src.storages.sql.models.workshops import Workshop, Timeslot, CheckIn +from src.storages.sql.models.linked import LinkedCalendar + +# from src.storages.sql.models.workshops import Workshop, Timeslot, CheckIn __all__ = [ "Base", @@ -16,7 +18,8 @@ "EventPatch", "UserXFavoriteEventGroup", "Tag", - "Workshop", - "Timeslot", - "CheckIn", + # "Workshop", + # "Timeslot", + # "CheckIn", + "LinkedCalendar", ] diff --git a/src/storages/sql/models/linked.py b/src/storages/sql/models/linked.py new file mode 100644 index 0000000..19df8b5 --- /dev/null +++ b/src/storages/sql/models/linked.py @@ -0,0 +1,33 @@ +__all__ = ["LinkedCalendar"] + +from typing import TYPE_CHECKING + +from sqlalchemy import ForeignKey, String, Boolean, UniqueConstraint +from sqlalchemy.orm import mapped_column, Mapped, relationship + +from src.storages.sql.models.__mixin__ import IdMixin, NameMixin, DescriptionMixin +from src.storages.sql.models.base import Base + +if TYPE_CHECKING: + from src.storages.sql.models.users import User + + +class LinkedCalendar(Base, IdMixin, NameMixin, DescriptionMixin): + __tablename__ = "linked_calendars" + + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False) + alias: Mapped[str] = mapped_column(String(255), nullable=False) + + url: Mapped[str] = mapped_column(nullable=False) + + is_active: Mapped[bool] = mapped_column(Boolean(), nullable=False, default=True) + + user: Mapped["User"] = relationship( + "User", + back_populates="linked_calendars", + ) + + color: Mapped[str] = mapped_column(String(255), nullable=True) + + # constraint + __table_args__ = (UniqueConstraint("user_id", "alias", name="unique_user_id_alias"),) diff --git a/src/storages/sql/models/users.py b/src/storages/sql/models/users.py index a2c1c52..5c39ac5 100644 --- a/src/storages/sql/models/users.py +++ b/src/storages/sql/models/users.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from src.storages.sql.models.event_groups import EventGroup from src.storages.sql.models.event_groups import UserXFavoriteEventGroup + from src.storages.sql.models.linked import LinkedCalendar class User(Base, IdMixin): @@ -34,3 +35,10 @@ class User(Base, IdMixin): "favorites_association", "event_group", ) + + linked_calendars: Mapped[list["LinkedCalendar"]] = relationship( + "LinkedCalendar", + back_populates="user", + cascade="all, delete-orphan", + passive_deletes=True, + )