From a7280d24baf2aa425cddc87786ae04f950d8698c Mon Sep 17 00:00:00 2001 From: onstabb Date: Fri, 15 Dec 2023 15:15:08 +0100 Subject: [PATCH] - Added attributes `initiator_last_update_at and`, 'respondent_last_update_model' and validation functionality to Contact model - Added `id` attribute to EventOut - `ReportReason` was changed to `ReportType` - Library versions updated --- requirements-base.txt | 7 +-- src/admin/views.py | 2 +- src/contacts/enums.py | 8 +++ src/contacts/models.py | 70 +++++++++++++++++++++++-- src/contacts/routers.py | 42 ++++++++------- src/contacts/schemas.py | 36 ++++++++----- src/contacts/service.py | 81 ++++++++++++++++------------- src/events/schemas.py | 2 + src/reports/enums.py | 2 +- src/reports/models.py | 4 +- src/reports/schemas.py | 4 +- tests/factories/factories.py | 8 +-- tests/reports/test_service.py | 2 +- tests/test_contacts/test_api.py | 2 +- tests/test_contacts/test_service.py | 6 +-- 15 files changed, 183 insertions(+), 93 deletions(-) diff --git a/requirements-base.txt b/requirements-base.txt index be45382..20ddb63 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -1,15 +1,15 @@ -fastapi~=0.104.1 +fastapi~=0.105.0 pydantic~=2.5.2 python-jose~=3.3.0 mongoengine~=0.27.0 Pillow~=10.1.0 passlib~=1.7.4 python-dotenv~=1.0.0 -phonenumbers~=8.13.25 +phonenumbers~=8.13.26 telesign~=2.2.2 bcrypt~=4.0.1 apscheduler~=3.10.4 -pymongo~=4.6.0 +pymongo~=4.6.1 python-multipart~=0.0.6 pytz~=2023.3 vincenty~=0.1.4 @@ -17,3 +17,4 @@ sse-starlette~=1.8.2 boto3~=1.29.6 starlette-admin~=0.12.2 itsdangerous~=2.1.2 + diff --git a/src/admin/views.py b/src/admin/views.py index e380143..00c65cb 100644 --- a/src/admin/views.py +++ b/src/admin/views.py @@ -101,7 +101,7 @@ class ReportView(ModelView): fields = [ "initiator", "respondent", - "reason", + "type", "additional_info", "closed", ] diff --git a/src/contacts/enums.py b/src/contacts/enums.py index b9c8a29..e6700cf 100644 --- a/src/contacts/enums.py +++ b/src/contacts/enums.py @@ -6,3 +6,11 @@ class ContactState(enum.StrEnum): ESTABLISHED = "established" REFUSED = "refused" BLOCKED = "blocked" + + +@enum.unique +class ContactUpdateAction(enum.StrEnum): + ESTABLISH = "establish" + REFUSE = "refuse" + BLOCK = "block" + SEEN = "seen" diff --git a/src/contacts/models.py b/src/contacts/models.py index fd08989..cb3b9ee 100644 --- a/src/contacts/models.py +++ b/src/contacts/models.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import TYPE_CHECKING, Self from mongoengine import ( ReferenceField, @@ -11,10 +12,14 @@ ) from mongoengine.base import EmbeddedDocumentList + from contacts.enums import ContactState from datehelpers import get_aware_datetime_now from models import BaseDocument +if TYPE_CHECKING: + from users.models import User + # None # established # refused # blocked CONTACT_RESULT_TABLE = ( @@ -32,17 +37,72 @@ class Message(EmbeddedDocument): class Contact(BaseDocument): - initiator = ReferenceField('User', required=True) - respondent = ReferenceField('User', required=True) - initiator_state = EnumField(ContactState, null=True) # type: ContactState | None - respondent_state = EnumField(ContactState, null=True) # type: ContactState | None - status = EnumField(ContactState, null=True) # type: ContactState | None + initiator = ReferenceField('User', required=True) # type: User + respondent = ReferenceField('User', required=True) # type: User + initiator_state = EnumField(ContactState) # type: ContactState + respondent_state = EnumField(ContactState, null=True, required=False) # type: ContactState | None + initiator_last_update_at = DateTimeField(default=get_aware_datetime_now) # type: datetime + respondent_last_update_at = DateTimeField(required=False, null=True) # type: datetime | None + status = EnumField(ContactState, null=True, required=False) # type: ContactState | None messages = EmbeddedDocumentListField(Message) # type: EmbeddedDocumentList[Message] meta = { "indexes": [{"fields": ["initiator", "respondent"], "unique": True}] } + def __init__(self, *args, **values) -> None: + super().__init__(*args, **values) + self._initial_initiator_state = self.initiator_state + self._initial_respondent_state = self.respondent_state + + @staticmethod + def get_contact_status(state_1: ContactState | None, state_2: ContactState | None) -> ContactState | None: + enum_list: list[ContactState | None] = [None] + list(ContactState) + index_1: int = enum_list.index(state_1) + index_2: int = enum_list.index(state_2) + return CONTACT_RESULT_TABLE[index_1][index_2] + + def clean(self) -> None: + if self.respondent_last_update_at is None and self.respondent_state: + self.respondent_last_update_at = get_aware_datetime_now() + + if self._initial_initiator_state != self.initiator_state: + self._initial_initiator_state = self.initiator_state + self.initiator_last_update_at = get_aware_datetime_now() + elif self._initial_respondent_state != self.respondent_state: + self._initial_respondent_state = self.respondent_state + self.respondent_last_update_at = get_aware_datetime_now() + + self.status = self.get_contact_status(self.initiator_state, self.respondent_state) + return + @property def established(self) -> bool: return self.status == ContactState.ESTABLISHED + + def reset_field_to_dbref(self, field_name: str) -> Self: + document = getattr(self, field_name) + setattr(self, field_name, document.to_dbref()) + return self + + def reset_current_user(self, current_user: 'User') -> Self: + """ + This function updates the self instance by setting the appropriate user ID based on the role of the provided 'user' in the contact. + If 'user' is the respondent, the 'respondent' attribute in the self instance is set to the user's ID. + If 'user' is the initiator, the 'initiator' attribute is set to the user's ID. + The purpose of this function is a resolving who is the opposite user for current user. + + :param current_user: The user for which we want to define the opposite user. + :return: self instance + :raise ValueError: Contact does not contain this user + """ + + if current_user == self.initiator: + self.initiator = current_user.id + elif current_user == self.respondent: + self.respondent = current_user.id + else: + ValueError(f"Contact does not contain user {current_user.id}") + + return self + diff --git a/src/contacts/routers.py b/src/contacts/routers.py index 13e7c4e..6f096ea 100644 --- a/src/contacts/routers.py +++ b/src/contacts/routers.py @@ -1,9 +1,9 @@ from fastapi import APIRouter, HTTPException, status, Query from contacts import config, service -from contacts.enums import ContactState +from contacts.enums import ContactState, ContactUpdateAction from contacts.models import Contact -from contacts.schemas import ContactStateIn, ContactCreateDataIn, ContactOut, MessageIn, MessageOut +from contacts.schemas import ContactUpdateIn, ContactCreateDataIn, ContactOut, MessageIn, MessageOut from notifications.enums import NotificationType from notifications.manager import notification_manager from users.dependencies import CurrentActiveCompletedUser, TargetActiveCompletedUser, get_active_completed_user @@ -19,12 +19,14 @@ def get_contacts( contact_status: ContactState = Query(alias="status", default=ContactState.ESTABLISHED), limit: int = 0, ): - return service.get_contacts_for_user(current_user, limit=limit, status=contact_status) + return service.get_contacts_by_user(current_user, limit=limit, status=contact_status) @router.get("/{user_id}", response_model=ContactOut) def get_contact(current_user: CurrentActiveCompletedUser, target_user: TargetActiveCompletedUser): - return service.get_contact_by_users_pair(current_user, target_user) + contact = service.get_contact_by_users_pair(current_user, target_user) + contact.reset_current_user(current_user) + return contact @router.post("", response_model=ContactOut, status_code=status.HTTP_201_CREATED) @@ -36,7 +38,6 @@ def create_contact(data_in: ContactCreateDataIn, current_user: CurrentActiveComp target_user = get_active_completed_user(data_in.user_id) contact: Contact | None = service.get_contact_by_users_pair(current_user, target_user) - if contact: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"Contact with profile `{target_user.id}` already exists" @@ -49,31 +50,34 @@ def create_contact(data_in: ContactCreateDataIn, current_user: CurrentActiveComp target_user.id, NotificationType.LIKE ) - + contact.reset_current_user(current_user) return contact @router.patch("/{user_id}", response_model=ContactOut) -def update_contact_state( +def update_contact( target_user: TargetActiveCompletedUser, current_user: CurrentActiveCompletedUser, - state_data: ContactStateIn, + update_data: ContactUpdateIn, ): - contact = service.get_contact_by_users_pair(current_user, target_user, use_id_for_current_user=False) + contact = service.get_contact_by_users_pair(current_user, target_user) if not contact: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contact doesn't exists") - if contact.status == ContactState.BLOCKED or (contact.status and state_data.action != ContactState.BLOCKED): + if contact.status == ContactState.BLOCKED: return contact - service.update_contact_status(state_data, current_user, contact) - if contact.established and current_user == contact.respondent: + if contact.status and update_data.action not in (ContactUpdateAction.BLOCK, ContactUpdateAction.SEEN): + return contact + + service.update_contact(update_data.action, current_user, contact) + if update_data.action == ContactUpdateAction.ESTABLISH and contact.established and current_user == contact.respondent: notification_manager.put_notification( UserPublicOut.model_validate(current_user, from_attributes=True), - contact.initiator, - NotificationType.CONTACT_ESTABLISHED + recipient_id=contact.initiator, + notification_type=NotificationType.CONTACT_ESTABLISHED ) - + contact.reset_current_user(current_user) return contact @@ -83,14 +87,16 @@ def send_message( current_user: CurrentActiveCompletedUser, target_user: TargetActiveCompletedUser, ): - contact = service.get_contact_by_users_pair(current_user, target_user, use_id_for_current_user=False) + contact = service.get_contact_by_users_pair(current_user, target_user) if not contact or not contact.established: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Contact must be established") if service.get_messages_count_from_sender(contact, current_user) >= config.MAX_SENT_MESSAGES: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Limit of the messages has been exceeded") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Limit of the messages is exceeded") message = service.create_message(contact, sender=current_user, message_in=message_in) message_out = MessageOut.model_validate(message, from_attributes=True) - notification_manager.put_notification(message_out, recipient_id=target_user.id, notification_type=NotificationType.MESSAGE) + notification_manager.put_notification( + message_out, recipient_id=target_user.id, notification_type=NotificationType.MESSAGE + ) return message_out diff --git a/src/contacts/schemas.py b/src/contacts/schemas.py index f9805ec..de2f5c9 100644 --- a/src/contacts/schemas.py +++ b/src/contacts/schemas.py @@ -1,10 +1,12 @@ from datetime import datetime from functools import cached_property +from typing import Any, Self -from pydantic import AliasChoices, BaseModel, Field, constr, computed_field +from bson import ObjectId +from pydantic import AliasChoices, BaseModel, Field, constr, computed_field, model_validator from contacts import config -from contacts.enums import ContactState +from contacts.enums import ContactState, ContactUpdateAction from models import PydanticObjectId, DateTimeFromObjectId from users.schemas import UserPublicOut @@ -25,21 +27,28 @@ class MessageOut(MessageBase): class ContactBaseOut(BaseModel): initiator: UserPublicOut | PydanticObjectId = Field(exclude=True) respondent: UserPublicOut | PydanticObjectId = Field(exclude=True) - opposite_user: UserPublicOut | None = Field(exclude=True, default=None) + initiator_last_update_at: datetime = Field(exclude=True) + respondent_last_update_at: datetime | None = Field(exclude=True) + + opposite_user: UserPublicOut | None = None + opposite_user_last_update_at: datetime | None = None created_at: DateTimeFromObjectId = Field(validation_alias=AliasChoices("_id", "id"), ) - @computed_field - @cached_property - def user(self) -> UserPublicOut | None: + @model_validator(mode="after") + def assign_opposite_user(self) -> Self: """ This is the opposite user resolver for particular user """ if self.opposite_user: - return self.opposite_user + return self if isinstance(self.initiator, UserPublicOut) and isinstance(self.respondent, PydanticObjectId): - return self.initiator + self.opposite_user = self.initiator + self.opposite_user_last_update_at = self.initiator_last_update_at elif isinstance(self.respondent, UserPublicOut) and isinstance(self.initiator, PydanticObjectId): - return self.respondent + self.opposite_user = self.respondent + self.opposite_user_last_update_at = self.respondent_last_update_at + else: + ValueError("Incorrect input") - return None + return self class ContactOut(ContactBaseOut): @@ -47,11 +56,12 @@ class ContactOut(ContactBaseOut): messages: list[MessageOut] -class ContactStateIn(BaseModel): - action: ContactState +class ContactUpdateIn(BaseModel): + action: ContactUpdateAction -class ContactCreateDataIn(ContactStateIn): +class ContactCreateDataIn(BaseModel): + state: ContactState user_id: PydanticObjectId diff --git a/src/contacts/service.py b/src/contacts/service.py index f05e8fa..9902d68 100644 --- a/src/contacts/service.py +++ b/src/contacts/service.py @@ -3,13 +3,14 @@ DoesNotExist ) -from contacts.enums import ContactState -from contacts.models import Contact, CONTACT_RESULT_TABLE, Message -from contacts.schemas import ContactStateIn, ContactCreateDataIn, MessageIn +from contacts.enums import ContactState, ContactUpdateAction +from contacts.models import Contact, Message +from contacts.schemas import ContactCreateDataIn, MessageIn +from datehelpers import get_aware_datetime_now from users.models import User -def get_contacts_for_user(user: User, *, limit: int = 0, **filters) -> list[dict]: +def get_contacts_by_user(user: User, *, limit: int = 0, **filters) -> list[dict]: pipeline = [ { "$match": { @@ -27,7 +28,18 @@ def get_contacts_for_user(user: User, *, limit: int = 0, **filters) -> list[dict "then": "$respondent", "else": "$initiator" } - } + }, + } + }, + { + "$addFields": { + "opposite_user_last_update_at": { + "$cond": { + "if": {"$eq": ["$initiator", "$opposite_user"]}, + "then": "$initiator_last_update_at", + "else": "$respondent_last_update_at" + } + }, } }, { @@ -51,63 +63,64 @@ def get_contacts_for_user(user: User, *, limit: int = 0, **filters) -> list[dict return result -def get_contact_by_users_pair( - current_user: User, target_user: User, use_id_for_current_user: bool = True -) -> Contact | None: +def get_contact_by_users_pair(current_user: User, target_user: User) -> Contact | None: """ Retrieve a Contact instance for the given pair of users. :param current_user: The first user in the pair. :param target_user: The second user in the pair. - :param use_id_for_current_user: if True then current_user object in found contact will be changed to the ObjectId :return: A Contact instance if it exists. - - Note: if a contact is found and parameter `use_id_for_current_user` is True, - this function updates the Contact instance by setting the appropriate user ID based on the role of the provided - 'user' in the contact. If 'user' is the respondent, the 'respondent' attribute in the Contact instance is set to the - user's ID. If 'user' is the initiator, the 'initiator' attribute is set to the user's ID. """ query = ( - (Query(initiator=current_user) & Query(respondent=target_user)) | - (Query(respondent=current_user) & Query(initiator=target_user)) + (Query(initiator=current_user) & Query(respondent=target_user)) + | (Query(respondent=current_user) & Query(initiator=target_user)) ) try: contact: Contact = Contact.objects.get(query) except DoesNotExist: return None - # From this we cand understand who is target user - if use_id_for_current_user: - if current_user == contact.respondent: - contact.respondent = current_user.id - else: - contact.initiator = current_user.id - return contact def create_contact_by_initiator(initiator: User, respondent: User, data_in: ContactCreateDataIn) -> Contact: - contact = Contact(initiator=initiator, respondent=respondent, initiator_state=data_in.action) - contact.status = get_contact_status(contact.respondent_state, contact.initiator_state) + contact = Contact(initiator=initiator, respondent=respondent, initiator_state=data_in.state) contact.save() return contact -def update_contact_status(contact_state_data: ContactStateIn, user: User, contact: Contact) -> Contact: +def update_contact( + contact_update_action: ContactUpdateAction, user: User, contact: Contact, save: bool = True +) -> Contact: + user_role = "" if contact.initiator == user: - contact.initiator_state = contact_state_data.action - + user_role = "initiator" elif contact.respondent == user: - contact.respondent_state = contact_state_data.action + user_role = "respondent" + else: + ValueError(f"Contact does not contain user {user.id}") + + if contact_update_action != ContactUpdateAction.SEEN: + action_states = { + ContactUpdateAction.BLOCK: ContactState.BLOCKED, + ContactUpdateAction.REFUSE: ContactState.REFUSED, + ContactUpdateAction.ESTABLISH: ContactState.ESTABLISHED, + } + if state := action_states.get(contact_update_action): + setattr(contact, f"{user_role}_state", state) + else: + setattr(contact, f"{user_role}_last_update_at", get_aware_datetime_now()) + + if save: + contact.save() - contact.status = get_contact_status(contact.respondent_state, contact.initiator_state) - contact.save() return contact def create_message(contact: Contact, sender: User, message_in: MessageIn) -> Message: message = contact.messages.create(sender=sender, text=message_in.text) + update_contact(ContactUpdateAction.SEEN, sender, contact, save=False) contact.save() return message @@ -118,9 +131,3 @@ def get_messages_count_from_sender(contact: Contact, sender: User) -> int: return len(contact.messages.filter(sender=sender)) - -def get_contact_status(state_1: ContactState | None, state_2: ContactState | None) -> ContactState | None: - enum_list: list[ContactState | None] = [None] + list(ContactState) - index_1: int = enum_list.index(state_1) - index_2: int = enum_list.index(state_2) - return CONTACT_RESULT_TABLE[index_1][index_2] diff --git a/src/events/schemas.py b/src/events/schemas.py index 0147a46..d46df83 100644 --- a/src/events/schemas.py +++ b/src/events/schemas.py @@ -4,9 +4,11 @@ from location.geopoint import GeoPoint from location.schemas import LocationProjectBase +from models import PydanticObjectId class EventOut(LocationProjectBase): + id: PydanticObjectId title: str description: str image_urls: list[HttpUrl] diff --git a/src/reports/enums.py b/src/reports/enums.py index 6211754..120cfd9 100644 --- a/src/reports/enums.py +++ b/src/reports/enums.py @@ -2,7 +2,7 @@ @enum.unique -class ReportReason(enum.StrEnum): +class ReportType(enum.StrEnum): INAPPROPRIATE_BEHAVIOR = "inappropriate_behavior" INAPPROPRIATE_CONTENT = "inappropriate_content" FRAUD_OR_AD = "fraud_or_ad" diff --git a/src/reports/models.py b/src/reports/models.py index eb0c349..44b1ea4 100644 --- a/src/reports/models.py +++ b/src/reports/models.py @@ -1,12 +1,12 @@ from mongoengine import ReferenceField, StringField, EnumField, BooleanField from models import BaseDocument -from reports.enums import ReportReason +from reports.enums import ReportType class Report(BaseDocument): initiator = ReferenceField('User') respondent = ReferenceField('User') - reason = EnumField(ReportReason) # type: ReportReason + type = EnumField(ReportType) # type: ReportType additional_info = StringField(required=False) # type: str closed = BooleanField(default=False) # type: bool diff --git a/src/reports/schemas.py b/src/reports/schemas.py index 92e769a..afb3f08 100644 --- a/src/reports/schemas.py +++ b/src/reports/schemas.py @@ -2,10 +2,10 @@ from models import PydanticObjectId from reports.config import REPORT_ADDITIONAL_INFO_MAX_LENGTH -from reports.enums import ReportReason +from reports.enums import ReportType class ReportIn(BaseModel): respondent: PydanticObjectId - reason: ReportReason + type: ReportType additional_info: constr(max_length=REPORT_ADDITIONAL_INFO_MAX_LENGTH) | None diff --git a/tests/factories/factories.py b/tests/factories/factories.py index 000fdf2..46740b5 100644 --- a/tests/factories/factories.py +++ b/tests/factories/factories.py @@ -3,11 +3,10 @@ import factory from factory import fuzzy -from contacts import service as contact_service from contacts.models import Contact, Message, ContactState from events.models import Event from location.database import geonames_db -from reports.enums import ReportReason +from reports.enums import ReportType from reports.models import Report from userprofile import config as profile_config from userprofile.enums import Gender, ResidenceLength, ResidencePlan @@ -73,9 +72,6 @@ class Meta: respondent = factory.SubFactory(UserFactory) initiator_state = factory.Iterator([contact for contact in ContactState] + [None]) respondent_state = factory.Iterator([contact for contact in ContactState] + [None]) - status = factory.LazyAttribute( - lambda contact: contact_service.get_contact_status(contact.initiator_state, contact.respondent_state) - ) class Params: active_dialog = factory.Trait( @@ -135,5 +131,5 @@ class Meta: initiator = factory.SubFactory(UserFactory) respondent = factory.SubFactory(UserFactory) - reason = factory.Iterator(ReportReason) + type = factory.Iterator(ReportType) additional_info = factory.Faker("paragraph", nb_sentences=5) diff --git a/tests/reports/test_service.py b/tests/reports/test_service.py index 24939ce..e564532 100644 --- a/tests/reports/test_service.py +++ b/tests/reports/test_service.py @@ -5,6 +5,6 @@ def test_create_report(report_factory, user_factory): initiator, respondent = user_factory.create_batch(size=2, ) report = report_factory.build(initiator=initiator, respondent=respondent) - report_in = ReportIn(respondent=respondent.id, reason=report.reason, additional_info=report.additional_info) + report_in = ReportIn(respondent=respondent.id, type=report.type, additional_info=report.additional_info) assert service.create_report(initiator, respondent, report_in) \ No newline at end of file diff --git a/tests/test_contacts/test_api.py b/tests/test_contacts/test_api.py index f0c26c5..d6cf4ee 100644 --- a/tests/test_contacts/test_api.py +++ b/tests/test_contacts/test_api.py @@ -9,5 +9,5 @@ def test_get_contact(client, contact_factory): response = client.get(f"/api/v1/users/me/contacts/{target_user.id}") response_data = response.json() - assert response_data["user"]["id"] == str(target_user.id) + assert response_data["opposite_user"]["id"] == str(target_user.id) assert response_data["status"] == created_contact.status.value diff --git a/tests/test_contacts/test_service.py b/tests/test_contacts/test_service.py index b3cfb50..a89cac0 100644 --- a/tests/test_contacts/test_service.py +++ b/tests/test_contacts/test_service.py @@ -7,7 +7,7 @@ def test_create_contact(user_factory): first_user, second_user = user_factory.create(), user_factory.create() contact = service.create_contact_by_initiator( - first_user, second_user, ContactCreateDataIn(action=ContactState.ESTABLISHED, user_id=second_user.id) + first_user, second_user, ContactCreateDataIn(state=ContactState.ESTABLISHED, user_id=second_user.id) ) assert contact.initiator == first_user @@ -19,7 +19,7 @@ def test_get_contacts_for_user(contact_factory): found_contacts = [ ContactOut.model_validate(document) - for document in service.get_contacts_for_user(contact.initiator, status=ContactState.ESTABLISHED) + for document in service.get_contacts_by_user(contact.initiator, status=ContactState.ESTABLISHED) ] for contact_out in found_contacts: @@ -36,7 +36,7 @@ def test_get_contact_by_user_pair(contact_factory): found_contact = service.get_contact_by_users_pair(current_user, target_user) assert found_contact == contact - assert found_contact.initiator == current_user.id + assert found_contact.initiator == current_user assert found_contact.respondent == target_user