commit b22477ef13ba6cec7577567975f86db386cd8c14 Author: Kulvir Singh Date: Mon Dec 22 20:40:55 2025 +0530 WOW diff --git a/01-schema-refactoring.md b/01-schema-refactoring.md new file mode 100644 index 0000000..2e10b81 --- /dev/null +++ b/01-schema-refactoring.md @@ -0,0 +1,466 @@ +## Problems + +### **Data Split** +- `MasterSettingsModel` stores: key, question, instructions, category etc.... +- `ChecklistItem` stores: answer (expected value) +- To get a complete "checklist item", need to JOIN both tables. +- Also current `MasterSettings` store same keyterm for multiple checklists/playbooks. + - meaning if a keyterm is changed but for one checklists it will change for others too + - to prevent such scenarios, funny strategies need to be implemented in code + - **conclusion** unnecessary complexity is introduced. + +### **Funny ways versioning** +- versioning means having older versions and newer versions and maintains a relation b/w them tooo so we know what change and how. +- currently no such versioning system is there. + + +### **complex queries for simple things** + +```python +# Current: Get checklist with all items (3 queries + manual merge) +checklist = ChecklistMetadata.objects.get(checklist_id=checklist_id) +checklist_items = ChecklistItem.objects.filter(checklist_id=checklist_id) +keyterm_ids = [item["keyterm_id_id"] for item in checklist_items] +keyterms = MasterSettingsModel.objects.filter(RowKey__in=keyterm_ids) +# Then manually merge... +``` + +### **`is_active` for checklists** + +- can be null/none - no active checklist found +- race conditions when switching active checklist +- scope - active for which team?? which contract type??? +- can delete active checklist accidentally + +--- + +### Goals + +- **Reduce Joins and Complexity** +- **Versioning**: Full history for keyterms, checklists, and contracts + +### Document (File Storage) + +```python +class Document(): + class DocumentType(models.TextChoices): + CONTRACT = "CONTRACT", "Contract" + CHECKLIST = "CHECKLIST", "Checklist" + PLAYBOOK = "PLAYBOOK", "Playbook" + + class FileType(models.TextChoices): + DOC = "DOC", "Word Document (Legacy)" + PDF = "PDF", "PDF" + DOCX = "DOCX", "Word Document" + CSV = "CSV", "CSV" + XLSX = "XLSX", "Excel" + + id = models.UUIDField(db_index=True) + + organization = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + related_name="documents" + ) + user = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + ) + + document_type = models.CharField( + max_length=20, + choices=DocumentType.choices + ) + name = models.CharField(max_length=500) + blob_url = models.URLField(max_length=2000) + file_type = models.CharField(max_length=20, choices=FileType.choices) + size_bytes = models.BigIntegerField() + total_pages = models.IntegerField(null=True, blank=True) + + # versioning ezzz + version = models.IntegerField(default=1) + previous_version = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="next_versions" + ) + is_current = models.BooleanField(default=True) + + def create_new_version( + self, + blob_url: str, + uploaded_by, + **file_metadata + ) -> "Document": + self.is_current = False + self.save(update_fields=["is_current", "updated_at"]) + + return Document.objects.create( + id=self.id, + previous_version=self, + version=self.version + 1, + is_current=True, + organization=self.organization, + uploaded_by=uploaded_by, + document_type=self.document_type, + name=file_metadata.get("name", self.name), + blob_url=blob_url, + file_type=file_metadata.get("file_type", self.file_type), + size_bytes=file_metadata.get("size_bytes", self.size_bytes), + total_pages=file_metadata.get("total_pages"), + ) + + @classmethod + def get_current(cls, id: str) -> "Document": + return cls.objects.get(original_id=original_id, is_current=True) + + @classmethod + def get_version_history(cls, original_id: str): + return cls.objects.filter(original_id=original_id).order_by("version") + + @property + def size_kb(self) -> float: + return self.size_bytes / 1024 +``` + +### Checklist + +```python +class Checklist(): + document = models.ForeignKey( + "Document", + on_delete=models.SET_NULL, + null=True, + blank=True, + ) + + id = models.UUIDField(db_index=True) + + version = models.IntegerField(default=1) + previous_version = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="next_versions" + ) + is_current = models.BooleanField(default=True) + + organization = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + ) + + created_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + ) + + name = models.CharField(max_length=200) + description = models.TextField(blank=True) +``` + +### Keyterm + +```python +class Keyterm(): + class KeytermType(models.TextChoices): + ANALYSIS = "ANALYSIS", "Analysis" + CHECKLIST = "CHECKLIST", "Checklist" + METADATA = "METADATA", "Metadata" + + id = models.UUIDField(db_index=True) + + version = models.IntegerField(default=1) + previous_version = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="next_versions" + ) + is_current = models.BooleanField(default=True) + + organization = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + ) + + key = models.CharField(max_length=500) + question = models.TextField() + instructions = models.TextField(blank=True) + + type = models.CharField( + choices=KeytermType.choices, + default=KeytermType.ANALYSIS + ) + + expected_answer = models.TextField(null=True, blank=True) + + is_active = models.BooleanField(default=True) +``` + +### Contract + +```python +class Contract(): + class AnalysisStatus(models.TextChoices): + PENDING = "PENDING", "Pending" + ACCEPTED = "ACCEPTED", "Accepted" + REJECTED = "REJECTED", "Rejected" + + class ContractType(models.TextChoices): + MSA = "MSA", "msa" + NDA = "NDA", "NDA" + # .... many more + + document = models.OneToOneField( + "Document", + on_delete=models.CASCADE, + related_name="contract" + ) + + organization = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + related_name="contracts" + ) + + contract_type = models.CharField(choices=ContractType) + + analysis_status = models.CharField( + choices=AnalysisStatus.choices, + default=AnalysisStatus.PENDING + ) + + analyzed_at = models.DateTimeField(null=True, blank=True) + analyzed_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + related_name="analyzed_contracts" + ) + + def get_version_history(self): + return Document.get_version_history(self.document.original_id) +``` + +### **Analysis MODEL** +- large unstructured textual data +- Also while fetching might need to fetch multiple rows of data for same contract +- Hence it makes sense to store this data in the Mongo DB document storage. + - Easy to store unstructured data because its just JSON + - Easy to query analysis for any contract + + +### Contract specific data + +**Problem**: COI has tenant_name, property_name etc. MSA has parties, term_length. different contracts have different fields. + +**Options**: +- One table with all nullable fields, but it will be sparse and kinda messy +- Separate table per type (COI, MSA) -> lmaoo more schema tables than users +- JSON field -> flexible but no validations + gets messy after short amount of time + +**SOLUTION**: + +```python +class Contract(): + document = models.OneToOneField("Document", ...) + organization = models.ForeignKey("Organization", ...) + contract_type = models.CharField(choices=ContractType) + analysis_status = models.CharField(...) + ... + + +class COIContract(): + contract = models.OneToOneField(Contract, on_delete=models.CASCADE, related_name="coi_details") + tenant_name = models.CharField() + property_name = models.CharField() + property_unit = models.CharField() + expiry_date = models.DateField() + # ..... ezzzzz + + +class MSAContract(): + contract = models.OneToOneField(Contract, on_delete=models.CASCADE, related_name="msa_details") + party_a = models.CharField(max_length=500) + party_b = models.CharField(max_length=500) + term_months = models.IntegerField(null=True) + auto_renewal = models.BooleanField(default=False) +``` + +### **Strategy Pattern** for type specific logic + +```python +class IContractHandler(): + def create_metadata(self, contract: Contract, metadata: dict) -> Any: ... + def get_metadata(self, contract: Contract) -> dict: ... + +class COIHandler: + def create_metadata(self, contract: Contract, metadata: dict) -> COIDetails: + return COIDetails.objects.create( + contract=contract, + tenant_name=metadata.get("tenant_name", ""), + property_name=metadata.get("property_name", ""), + property_unit=metadata.get("property_unit", ""), + expiry_date=metadata.get("expiry_date"), + ) + + def get_metadata(self, contract: Contract) -> dict: + details = contract.coi_details + return { + "tenant_name": details.tenant_name, + "property_name": details.property_name, + "expiry_date": details.expiry_date, + } + + # ... similar methods +``` + +```python +class ContractService: + def __init__(self, ...): + + @transaction.atomic + def create_contract(self, file: BinaryIO, team_id: str = None, **metadata) -> Contract: + document = self.document_service.upload(file=file, ...) + contract_type = self._detect_contract_type(document) + + contract = Contract.objects.create( + document=document, + organization=self.organization, + contract_type=contract_type, + ) + + # implementation of strategy pattern in actual methods + handler = self.handler.get(contract_type) + handler.create_details(contract, metadata) + + return contract +``` + +**Benefits**: +- `Contract` table stays clean +- type specific data is normalized +- no changes to existing code when adding new types, adding new contract type = new detail table + new handler +- lastly, easy to test handlers in isolation + +--- + +### Dependency Injection + +```python +class IDocumentService(Protocol): + def upload(self, file: BinaryIO, document_type: str) -> Document: ... + def upload_new_version(self, document_id: str, file: BinaryIO) -> Document: ... + +class IChecklistService(Protocol): + def get_active_checklist(self, organization_id: str, contract_type: str, team_id: str = None) -> Checklist: ... + +class IContractService(Protocol): + def create_contract(self, file: BinaryIO, team_id: str = None, **metadata) -> Contract: ... + def replace_document(self, contract_id: str, file: BinaryIO) -> Contract: ... +``` + +```python +class DocumentService: + def __init__(self, organization: Organization, user: User): + self.organization = organization + self.user = user + + def upload(self, file: BinaryIO, document_type: str) -> Document: + pass + + def upload_new_version(self, document_id: str, file: BinaryIO) -> Document: + pass + +class ContractService: + def __init__( + self, + organization: Organization, + user: User, + document_service: IDocumentService, + checklist_service: IChecklistService + ): + self.organization = organization + self.user = user + self.document_service = document_service + self.checklist_service = checklist_service + + @transaction.atomic + def create_contract(self, file: BinaryIO, team_id: str = None, **metadata) -> Contract: + document = self.document_service.upload( + file=file, + document_type=Document.DocumentType.CONTRACT, + ) + + contract_type = self._detect_contract_type(document) + + checklist = self.checklist_service.get_active_checklist( + organization_id=str(self.organization.id), + contract_type=contract_type, + team_id=team_id + ) + + return Contract.objects.create( + document=document, + organization=self.organization, + checklist=checklist, + contract_type=contract_type, + **metadata + ) +``` + +```python +class ServiceFactory: + def __init__(self, organization: Organization, user: User): + self.organization = organization + self.user = user + self._document_service = None + self._checklist_service = None + + @property + def document_service(self) -> DocumentService: + if not self._document_service: + self._document_service = DocumentService(self.organization, self.user) + return self._document_service + + @property + def checklist_service(self) -> ChecklistService: + if not self._checklist_service: + self._checklist_service = ChecklistService(self.organization) + return self._checklist_service + + def contract_service(self) -> ContractService: + return ContractService( + organization=self.organization, + user=self.user, + document_service=self.document_service, + checklist_service=self.checklist_service + ) +``` + +--- + +### usage in views or anything + +```python +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def upload_contract(request): + user = request.user + organization = user.organization + + factory = ServiceFactory(organization, user) + contract_service = factory.contract_service() + + contract = contract_service.create_contract( + file=request.FILES["file"], + ) + + return Response({"contract_id": str(contract.id)}) +``` diff --git a/02-multiple-dashboards.md b/02-multiple-dashboards.md new file mode 100644 index 0000000..5b5924c --- /dev/null +++ b/02-multiple-dashboards.md @@ -0,0 +1,612 @@ +## Requirements + +- team has its own dashboard tab in the UI +- COIs for a team are only visible to that team members and super admins. +- team can have multiple checklists +- separate email agents +- user can belong to multiple teams + +--- + +## RBAC + +### Permissions + +```python +class Permission(models.TextChoices): + CONTRACT_VIEW = "contract:view" + CONTRACT_CREATE = "contract:create" + CONTRACT_ANALYZE = "contract:analyze" + CONTRACT_DELETE = "contract:delete" + + CHECKLIST_VIEW = "checklist:view" + CHECKLIST_CREATE = "checklist:create" + CHECKLIST_EDIT = "checklist:edit" + CHECKLIST_DELETE = "checklist:delete" + CHECKLIST_SET_ACTIVE = "checklist:set_active" + + TEAM_VIEW = "team:view" + TEAM_VIEW_ALL = "team:view:all" # super admin - see all teams + TEAM_MANAGE_MEMBERS = "team:manage_members" + TEAM_EDIT_SETTINGS = "team:edit_settings" + TEAM_DELETE = "team:delete" + + EMAIL_AGENT_VIEW = "email_agent:view" + EMAIL_AGENT_CONFIGURE = "email_agent:configure" + + USER_INVITE = "user:invite" + USER_MANAGE = "user:manage" +``` + +```python +class Role(): + class RoleType(models.TextChoices): + ADMIN = "ADMIN" + MASTER = "MASTER" + VIEWER = "VIEWER" + + name = models.CharField(max_length=50, unique=True) + description = models.TextField(blank=True) + permissions = models.JSONField(default=list) + + is_system = models.BooleanField(default=False) + + +ROLE_PERMISSIONS = { + "ADMIN": [ + "contract:view", "contract:create", "contract:analyze", "contract:delete", + "checklist:view", "checklist:create", "checklist:edit", "checklist:delete", "checklist:set_active", + "team:view", "team:view:all", "team:manage_members", "team:edit_settings", "team:delete", + "email_agent:view", "email_agent:configure", + "user:invite", "user:manage", + ], + "MASTER": [ + "contract:view", "contract:create", "contract:analyze", + "checklist:view", "checklist:create", "checklist:edit", "checklist:set_active", + "team:view", "team:manage_members", + "email_agent:view", + ], + "VIEWER": [ + "contract:view", + "checklist:view", + "team:view", + ], +} +``` + +### Permission + +```python +def get_user_permissions(user: User, team_id: str = None) -> set: + membership = TeamMembership.objects.filter( + user=user, + team_id=team_id, + deleted_at__isnull=True + ).select_related("role").first() + + if not membership: + return set() + + return set(membership.role.permissions) + +def has_permission(user: User, permission: str, team_id: str = None) -> bool: + return permission in get_user_permissions(user, team_id) + + +def has_team_access(user: User, team_id: str) -> bool: + if has_permission(user, "team:view:all"): + return True + + return TeamMembership.objects.filter( + user=user, + team_id=team_id, + deleted_at__isnull=True + ).exists() +``` + +```python +def require_permission(permission: str, team_id: UUID) + def decorator(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + user = request.user + team_id = kwargs.get(team_id_param) or request.data.get(team_id_param) + + if not has_permission(user, permission, team_id): + return Response( + { "error": "Permission denied" }, + status=status.HTTP_403_FORBIDDEN + ) + + return view_func(request, *args, **kwargs) + return wrapper + return decorator + + +def require_team_access(team_id_param: str = "team_id"): + """Decorator to check team membership""" + def decorator(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + team_id = kwargs.get(team_id_param) + + if not has_team_access(request.user, team_id): + return Response( + {"error": "Team not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + return view_func(request, *args, **kwargs) + return wrapper + return decorator +``` + +### Usage in Views + +```python +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@require_team_access("team_id") +@require_permission("contract:create", "team_id") +def upload_contract(request, team_id): + # user has access to team AND has contract:create permission +``` + +--- + +## Team + +```python +class Team(): + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + + organization = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + related_name="teams" + ) + + is_default = models.BooleanField(default=False) + settings = models.JSONField(default=dict) +``` + +```python +class TeamMembership(): + user = models.ForeignKey( + "User", + on_delete=models.CASCADE, + related_name="team_memberships" + ) + + team = models.ForeignKey( + Team, + on_delete=models.CASCADE, + related_name="memberships" + ) + + role = models.ForeignKey( + "Role", + on_delete=models.PROTECT, + related_name="team_memberships" + ) + + added_by = models.ForeignKey( + "User", + on_delete=models.SET_NULL, + null=True, + related_name="memberships_added" + ) +``` + +```python +class TeamEmailAgent(BaseModel): + team = models.OneToOneField( + Team, + on_delete=models.CASCADE, + related_name="email_agent" + ) + + provider_type = models.CharField( + max_length=20, + choices=ProviderType.choices, + default=ProviderType.MICROSOFT + ) + + inbox_email = models.EmailField(unique=True) + + ms_client_id = models.CharField(max_length=500, blank=True) + ms_client_secret = models.CharField(max_length=500, blank=True) # maybe encrypt this + ms_tenant_id = models.CharField(max_length=500, blank=True) + + subscription_id = models.CharField(max_length=500, null=True, blank=True) + subscription_expiry = models.DateTimeField(null=True, blank=True) + + # processing settings + # { + # "default_checklist_id": "uuid", + # "automated_reminder": {} ..... + # } + processing_settings = models.JSONField(default=dict) + + enabled = models.BooleanField(default=False) + + def get_access_token(self) -> str: + """Get OAuth access token for email operations""" + from assistantService.services.graph_service.service import GraphEmailService + + graph = GraphEmailService() + return graph.generate_access_token( + client_id=self.ms_client_id, + client_secret=self.ms_client_secret, + tenant_id=self.ms_tenant_id + ) +``` + +```python +class Contract(): + # existing fields + + team = models.ForeignKey( + "Team", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="contracts" + ) +``` + +```python +class Checklist(BaseModel): + # existing fields + + # NULL might mean this checklist is available org wide.... + team = models.ForeignKey( + "Team", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="checklists" + ) +``` + +## Team Management + +```python +class TeamService: + def __init__(self, user: User): + self.user = user + + def _check_permission(self, permission: str, team_id: str = None): + if not has_permission(self.user, permission, team_id): + raise PermissionError(f"Missing permission: {permission}") + + @transaction.atomic + def create_team( + self, + organization_id: str, + name: str, + description: str = "", + settings: Dict = None, + ) -> Team: + # only admins can create teams + self._check_permission("team:view:all") + + team = Team.objects.create( + organization_id=organization_id, + name=name, + description=description, + settings=settings or {} + ) + + # creator becomes admin of this team + admin_role = Role.objects.get(name="ADMIN") + TeamMembership.objects.create( + user=self.user, + team=team, + role=admin_role, + added_by=self.user + ) + + return team + + def get_user_teams(self, user: User) -> List[Dict[str, Any]]: + """Get all teams a user has access to""" + + memberships = TeamMembership.objects.filter( + user=user, + deleted_at__isnull=True + ).select_related("team", "role").order_by("team__settings__order", "team__name") + + teams = [] + for membership in memberships: + team = membership.team + teams.append({ + "id": str(team.id), + "name": team.name, + "description": team.description, + "settings": team.settings, + "role": membership.role.name, + "is_default": team.is_default, + }) + + return teams + + @transaction.atomic + def add_member( + self, + team_id: str, + user_id: str, + role_name: str, + ) -> TeamMembership: + # requires team:manage_members permission + self._check_permission("team:manage_members", team_id) + + team = Team.objects.get(id=team_id) + user = User.objects.get(RowKey=user_id, organization_id=team.organization_id) + role = Role.objects.get(name=role_name.upper()) + + # prevent assigning higher role than own + my_membership = TeamMembership.objects.get(user=self.user, team=team) + if self._role_level(role) > self._role_level(my_membership.role): + raise PermissionError("Cannot assign role higher than your own") + + membership, created = TeamMembership.objects.update_or_create( + team=team, + user=user, + defaults={ + "role": role, + "added_by": self.user, + "deleted_at": None, + } + ) + return membership + + def _role_level(self, role: Role) -> int: + levels = {"VIEWER": 1, "MASTER": 2, "ADMIN": 3} + return levels.get(role.name, 0) + + def remove_member(self, team_id: str, user_id: str) -> bool: + self._check_permission("team:manage_members", team_id) + + membership = TeamMembership.objects.filter( + team_id=team_id, + user_id=user_id, + deleted_at__isnull=True + ).first() + + if membership: + membership.deleted_at = timezone.now() + membership.save() + return True + + return False + + def get_team_stats(self, team_id: str) -> Dict[str, int]: + from assistantService.models import Contract + + today = date.today() + next_30 = today + timedelta(days=30) + + contracts = Contract.objects.filter( + team_id=team_id, + deleted_at__isnull=True + ) + + return { + "total_contracts": contracts.count(), + "pending": contracts.filter(analysis_status="PENDING").count(), + "accepted": contracts.filter(analysis_status="ACCEPTED").count(), + "rejected": contracts.filter(analysis_status="REJECTED").count(), + "expiring_in_30_days": contracts.filter( + expiry_date__range=(today, next_30) + ).count(), + "expired": contracts.filter(expiry_date__lt=today).count(), + "members": TeamMembership.objects.filter( + team_id=team_id, + deleted_at__isnull=True + ).count(), + } +``` + +## Dashboard Tabs + +```python +class DashboardService: + def __init__(self, user: User): + self.user = user + + def get_dashboard( + self, + team_id: str, + page: int = 1, + page_size: int = 10, + filters: Optional[Dict] = None + ) -> Dict[str, Any]: + # uses has_team_access from RBAC + if not has_team_access(self.user, team_id): + raise PermissionError("You don't have access to this team") + + team = Team.objects.get(id=team_id) + + query = Contract.objects.filter( + team_id=team_id, + deleted_at__isnull=True + ) + + if filters: + query = self._apply_filters(query, filters) + + query = query.order_by("-created_at") + + query = query.select_related( + "current_version", + "active_checklist", + "uploaded_by" + ) + + paginator = Paginator(query, page_size) + page_obj = paginator.get_page(page) + + contracts = [ + self._format_contract(c, team.organization.settings.get("timezone", "UTC")) + for c in page_obj + ] + + return { + "team": { + "id": str(team.id), + "name": team.name, + "settings": team.settings, + }, + "contracts": contracts, + "pagination": { + "current_page": page, + "total_pages": paginator.num_pages, + "total_count": paginator.count, + "has_next": page_obj.has_next(), + "has_previous": page_obj.has_previous(), + }, + "filters": self._get_filter_options(team_id), + } +``` + +### Email Agent Configuration + +```python +class TeamEmailAgentService: + def __init__(self, user: User): + self.user = user + + def get_config(self, team_id: str) -> Dict[str, Any]: + if not has_permission(self.user, "email_agent:view", team_id): + raise PermissionError("Cannot view email agent config") + + team = Team.objects.get(id=team_id) + + try: + agent = team.email_agent + return { + "is_configured": True, + "inbox_email": agent.inbox_email, + "provider_type": agent.provider_type, + "is_enabled": agent.is_enabled, + "processing_settings": agent.processing_settings, + "subscription_status": { + "active": agent.subscription_id is not None, + "expires_at": agent.subscription_expiry.isoformat() if agent.subscription_expiry else None, + }, + "last_sync_at": agent.last_sync_at.isoformat() if agent.last_sync_at else None, + } + except TeamEmailAgent.DoesNotExist: + return { + "is_configured": False, + } + + @transaction.atomic + def configure( + self, + team_id: str, + inbox_email: str, + ms_client_id: str, + ms_client_secret: str, + ms_tenant_id: str, + processing_settings: Dict = None + ) -> TeamEmailAgent: + # only ADMIN can configure email agent + if not has_permission(self.user, "email_agent:configure", team_id): + raise PermissionError("Cannot configure email agent") + + team = Team.objects.get(id=team_id) + + graph = GraphEmailService() + try: + token = graph.generate_access_token( + client_id=ms_client_id, + client_secret=ms_client_secret, + tenant_id=ms_tenant_id + ) + except Exception as e: + raise ValueError(f"Invalid Microsoft Graph credentials: {e}") + + agent, created = TeamEmailAgent.objects.update_or_create( + team=team, + defaults={ + "inbox_email": inbox_email, + "ms_client_id": ms_client_id, + "ms_client_secret": ms_client_secret, # TODO: encrypt + "ms_tenant_id": ms_tenant_id, + "processing_settings": processing_settings or {}, + } + ) + + return agent + + def enable(self, team_id: str) -> TeamEmailAgent: + if not has_permission(self.user, "email_agent:configure", team_id): + raise PermissionError("Cannot enable email agent") + + agent = TeamEmailAgent.objects.get(team_id=team_id) + + if not agent.ms_client_id: + raise ValueError("Email agent not configured") + + subscription = self._create_subscription(agent) + + agent.is_enabled = True + agent.subscription_id = subscription["id"] + agent.subscription_expiry = subscription["expirationDateTime"] + agent.save() + + return agent + + def disable(self, team_id: str) -> TeamEmailAgent: + if not has_permission(self.user, "email_agent:configure", team_id): + raise PermissionError("Cannot disable email agent") + + agent = TeamEmailAgent.objects.get(team_id=team_id) + + if agent.subscription_id: + self._delete_subscription(agent) + + agent.is_enabled = False + agent.subscription_id = None + agent.subscription_expiry = None + agent.save() + + return agent +``` + +--- + +## APIs + +```python +urlpatterns = [ + # team management + path("", views.list_teams), # team:view + path("", views.create_team), # team:view:all (admin only) + path("/", views.get_team), # team:view + membership + path("/", views.update_team), # team:edit_settings + path("/", views.delete_team), # team:delete + + # members + path("/members/", views.list_members), # team:view + path("/members/", views.add_member), # team:manage_members + path("/members//", views.remove_member), # team:manage_members + path("/members//role/", views.update_member_role),# team:manage_members + + # dashboard + path("/dashboard/", views.get_dashboard), # contract:view + path("/dashboard/stats/", views.get_dashboard_stats), + + # checklists + path("/checklists/", views.list_team_checklists), # checklist:view + + # email agent + path("/email-agent/", views.get_email_agent_config), # email_agent:view + path("/email-agent/", views.configure_email_agent), # email_agent:configure + path("/email-agent/enable/", views.enable_email_agent), # email_agent:configure + path("/email-agent/disable/", views.disable_email_agent), +] +```