"""Project model.""" from datetime import datetime, timezone from enum import Enum from typing import List, Callable, Optional, Type from sqlalchemy import desc, asc, func from constants.project_statuses import ( ProjectStatuses, PROJECT_STATUSES_MAPPING, ) from sqlalchemy.orm import joinedload from db import db from models.artist_team import ArtistTeam from models.campaign import Campaign from models.mixins.searchable import SearchableMixin from models.user import User from models.projects_history import ProjectHistoryItem from models.project_entity_type import ProjectEntityType, ProjectEntityAddType from models.media_plans import MediaPlan from models.product_family import ProjectProductFamily class ProjectCampaignStatus(Enum): PENDING = 0 APPROVED = 1 REJECTED = 2 class ProjectCampaign(db.Model): """ProjectCampaign model""" __tablename__ = "ProjectCampaign" id = db.Column(db.INTEGER, primary_key=True) project_id = db.Column(db.BigInteger, db.ForeignKey("Project.id", ondelete="CASCADE"), nullable=False, index=True) campaign_id = db.Column(db.BigInteger, db.ForeignKey("Campaign.id", ondelete="CASCADE"), nullable=False) status = db.Column(db.Integer, nullable=False, server_default="0") campaign = db.relationship("Campaign", back_populates='project_campaigns') project = db.relationship("Project", back_populates='project_campaigns') __table_args__ = ( db.UniqueConstraint("project_id", "campaign_id", name="uq_ProjectCampaign_gras_project_id_campaign_id"), ) class ProjectTargetItem(db.Model): __tablename__ = "ProjectTargetItem" __table_args__ = ( db.Index("ix_ProjectTargetItem_project_id_is_deleted_entity_type", "project_id", "is_deleted", "entity_type"), ) id = db.Column(db.BigInteger, db.Sequence("ProjectTargetItem_id_seq")) entity_id = db.Column( db.BigInteger, db.ForeignKey("Polymorphable.id", ondelete="CASCADE"), primary_key=True, index=True ) project_id = db.Column(db.BigInteger, db.ForeignKey("Project.id", ondelete="CASCADE"), primary_key=True) entity_type = db.Column(db.BigInteger, nullable=False) add_type = db.Column(db.BigInteger, nullable=False, server_default="1") is_deleted = db.Column(db.Boolean, nullable=False, server_default="FALSE") entity = db.relationship("Polymorphable") project = db.relationship("Project") def __init__(self, entity, type: ProjectEntityType, add_type: ProjectEntityAddType = ProjectEntityAddType.MANUALLY): self.entity = entity self.entity_type = type.value self.add_type = add_type.value def is_primary_artist(self) -> bool: return self.entity_type == ProjectEntityType.PRIMARY_ARTIST.value def is_featured_artist(self) -> bool: return self.entity_type == ProjectEntityType.FEATURED_ARTIST.value def is_artist(self) -> bool: return self.is_primary_artist() or self.is_featured_artist() def is_playlist(self) -> bool: return self.entity_type == ProjectEntityType.PLAYLIST.value def is_added_manually(self) -> bool: return self.add_type == ProjectEntityAddType.MANUALLY.value def is_locked(self) -> bool: return self.add_type == ProjectEntityAddType.LOCKED.value def __str__(self): type = "[Primary]" if self.is_primary_artist() else "[Featured]" source = "[PRS]" if self.is_added_by_prs() else "[GRAS]" return f"{type} {source} ({self.entity.external_id}) {self.entity.name}" class ProjectSource(Enum): PRS = "PRS" CCP = "CCP" class Project(SearchableMixin, db.Model): """Project model.""" __tablename__ = "Project" __searchable__ = { "name": {"type": "text", "analyzer": "autocomplete", "search_analyzer": "standard"}, "prs_title": {"type": "text", "analyzer": "autocomplete", "search_analyzer": "standard"}, "ccp_title": {"type": "text", "analyzer": "autocomplete", "search_analyzer": "standard"}, "prs_id": {"type": "text"}, "gras_id": {"type": "text"}, "ccp_id": {"type": "text"}, "dec_id": {"type": "text"}, "primary_artists_names": {"type": "text", "analyzer": "autocomplete", "search_analyzer": "standard"}, } id = db.Column(db.BigInteger, primary_key=True) name = db.Column(db.String, nullable=True) source = db.Column(db.String, nullable=False, server_default="PRS") currency = db.Column(db.String, nullable=False, server_default="USD") gras_project_code = db.Column(db.String, nullable=False) gras_title = db.Column(db.String, nullable=True) prs_project_code = db.Column(db.String, nullable=True) prs_title = db.Column(db.String, nullable=True) updated_by_import_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False) ccp_project_code = db.Column(db.String, nullable=True) ccp_title = db.Column(db.String, nullable=True) label_id = db.Column(db.Integer, db.ForeignKey("Label.id"), nullable=False) initial_start_date = db.Column(db.Date, nullable=True) end_date = db.Column(db.Date, nullable=True) is_deleted = db.Column(db.Boolean, nullable=False, server_default="FALSE") is_confidential = db.Column(db.Boolean, nullable=False, server_default="FALSE") allocation = db.Column(db.Numeric(12, 2), server_default="0", nullable=False) budget = db.Column(db.Numeric(12, 2), server_default="0", nullable=False) created_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, server_default=func.current_timestamp()) updated_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, server_default=func.current_timestamp()) last_edit_at = db.Column(db.TIMESTAMP(timezone=True), nullable=False, server_default=func.current_timestamp()) create_user_id = db.Column(db.Integer, db.ForeignKey("User.id"), nullable=False) edit_user_id = db.Column(db.Integer, db.ForeignKey("User.id"), nullable=False) notes = db.Column(db.String, nullable=True) is_claimed = db.Column(db.Boolean, nullable=False, server_default="FALSE") create_user = db.relationship("User", foreign_keys=[create_user_id]) edit_user = db.relationship("User", foreign_keys=[edit_user_id]) label = db.relationship("Label") target_items: List[ProjectTargetItem] = db.relationship( "ProjectTargetItem", cascade="all, delete-orphan", order_by=ProjectTargetItem.id ) campaigns: List[Campaign] = db.relationship( "Campaign", back_populates="project", primaryjoin="and_(Project.id == Campaign.project_id, Campaign.is_deleted == False)", ) media_plans: List[MediaPlan] = db.relationship("MediaPlan", back_populates="project") linkfire_projects = db.relationship("LinkfireProject", back_populates="project") project_campaigns: List[ProjectCampaign] = db.relationship("ProjectCampaign", back_populates="project") history = db.relationship("ProjectHistoryItem") user_projects = db.relationship("UserProject", back_populates="project") prs_project_budgets = db.relationship("PRSProjectBudget", primaryjoin="Project.id == PRSProjectBudget.project_id") prs_purchase_orders = db.relationship("PRSPurchaseOrder", primaryjoin="Project.id == PRSPurchaseOrder.project_id") project_product_families: List[ProjectProductFamily] = db.relationship( "ProjectProductFamily", back_populates="project" ) def touch(self): self.updated_at = datetime.now(timezone.utc) def touch_last_edit(self): self.last_edit_at = datetime.now(timezone.utc) def refresh_is_claimed(self): """Project should be marked as claimed if it has people in either artist or project team""" self.is_claimed = bool( (not self.is_confidential and self.artist_team and self.artist_team.artist_team_users) or self.user_projects ) @classmethod def es_query(cls: Type[db.Model]): return ( cls.query.filter_by(is_deleted=False) .options(joinedload(Project.target_items).joinedload(ProjectTargetItem.entity)) ) @property def start_date(self): return self.initial_start_date @property def latest_campaign_end_date(self): """End date of the last campaign. """ valid_campaigns = list(filter(lambda x: x.end_date is not None, self.campaigns)) if valid_campaigns: return max([campaign.end_date for campaign in valid_campaigns]) return None @property def has_been_claimed(self): return self.name is not None @property def earliest_campaign_start_date(self): """Start date of the first campaign. """ valid_campaigns = list(filter(lambda x: x.start_date is not None, self.campaigns)) if valid_campaigns: return min(campaign.start_date for campaign in valid_campaigns) return None @property def earliest_purchase_order_date(self): """Start date of the first purchase order. """ if self.prs_purchase_orders: return min(po.created_date for po in self.prs_purchase_orders) return None @property def latest_purchase_order_date(self): """Start date of the first purchase order. """ if self.prs_purchase_orders: return max(po.last_modified_date for po in self.prs_purchase_orders) return None @property def total_spend(self): """Total budget spend across all campaigns. Returns: decimal.Decimal: Total budget spend. """ return sum([a.planned_budget for a in self.campaigns if a.planned_budget]) @property def status_id(self) -> int: """Status ID of the project. Project status is determined by its start and end dates. Possible values are the same as for campaigns: Scheduled - 1, In Progress - 2, Completed - 3. Returns: Project status ID. """ if not self.start_date: return ProjectStatuses.SCHEDULED.value now = datetime.now(timezone.utc).date() if self.end_date is not None and now > self.end_date: return ProjectStatuses.COMPLETED.value if now >= self.start_date: return ProjectStatuses.INPROGRESS.value return ProjectStatuses.SCHEDULED.value @property def status(self) -> str: """String representation of project status. Returns: Project status name. """ return PROJECT_STATUSES_MAPPING[self.status_id] @property def created(self): """History record of project creation. Returns: ProjectHistory """ return self.__history(asc) @property def last_update(self): """History record of the most recent project update. Returns: ProjectHistory """ return self.__history(desc) @property def targets_names(self): return list(map(lambda item: item.entity.name, self.not_deleted_items())) @property def primary_artists_names(self): return list(map(lambda item: item.entity.name, self.not_deleted_items(ProjectEntityType.PRIMARY_ARTIST))) @property def display_name(self): return self.name or self.prs_title or self.ccp_title @property def prs_id(self): return self.prs_project_code @property def ccp_id(self): return self.ccp_project_code @property def gras_id(self): return self.gras_project_code @property def dec_id(self): return str(self.id) @property def target_joined_names(self): return " ".join(self.targets_names) @property def targets_type(self) -> Optional[ProjectEntityType]: if len(self.target_items) == 0: return None return ProjectEntityType(self.not_deleted_items()[0].entity_type) @property def artist_team(self) -> Optional[ArtistTeam]: target_item = self.prs_artist() if target_item is None: return None return next(filter(lambda x: x.label_id == self.label_id, target_item.entity.team), None) def not_deleted_items(self, entity_type: Optional[ProjectEntityType] = None) -> List[ProjectTargetItem]: items = filter(lambda x: not x.is_deleted, self.target_items) if entity_type: items = filter(lambda x: x.entity_type == entity_type.value, items) return list(items) def prs_artist(self) -> Optional[ProjectTargetItem]: artists = list(filter(lambda x: x.is_locked(), self.not_deleted_items())) if artists: return artists[0] return None def __history(self, sort_direction_func: Callable = asc): return ( db.session.query(ProjectHistoryItem) .join(User, ProjectHistoryItem.user_id == User.id) .filter(ProjectHistoryItem.project_id == self.id) .group_by(ProjectHistoryItem.id) .order_by(sort_direction_func(ProjectHistoryItem.created_at)) .first() )