From 5f8e54c0d40219946860b0d808374b55662f60f4 Mon Sep 17 00:00:00 2001 From: Tanaybaviskar Date: Mon, 13 Apr 2026 16:19:10 +0530 Subject: [PATCH 1/4] Add comprehensive test workflows and custom test runner for patent application system - Implemented end-to-end tests for various workflows (submission, revision, budget approval, director assignment, fee payment, and external filing) in est_workflows.py. - Created a custom Django test runner in setup_tests.py that generates CSV reports for test execution, defect logs, and artifact evaluations. - Added scaffolding for YAML specifications to facilitate structured test design documentation. - Ensured proper directory structure and initialization for test modules. --- FusionIIIT/Fusion/settings/common.py | 1 + FusionIIIT/Fusion/settings/development.py | 12 +- FusionIIIT/Fusion/urls.py | 10 +- FusionIIIT/applications/__init__.py | 1 + .../applications/patent_system/__init__.py | 1 + .../applications/patent_system/admin.py | 85 + .../patent_system/api/__init__.py | 0 .../patent_system/api/serializers.py | 325 ++++ .../applications/patent_system/api/urls.py | 90 + .../applications/patent_system/api/views.py | 887 ++++++++++ FusionIIIT/applications/patent_system/apps.py | 7 + .../patent_system/migrations/0001_initial.py | 198 +++ .../migrations/0002_auto_20260221_1708.py | 89 + .../migrations/0003_auto_20260325_0314.py | 96 + .../migrations/0004_auto_20260325_1810.py | 18 + .../patent_system/migrations/__init__.py | 0 .../applications/patent_system/models.py | 648 +++++++ .../applications/patent_system/selectors.py | 679 +++++++ .../applications/patent_system/services.py | 1554 +++++++++++++++++ .../patent_system/tests/__init__.py | 0 .../applications/patent_system/tests/base.py | 341 ++++ .../patent_system/tests/conftest.py | 10 + .../tests/reports/Artifact_Evaluation.csv | 40 + .../tests/reports/BR_Test_Design.csv | 82 + .../tests/reports/Defect_Log.csv | 119 ++ .../tests/reports/Module_Test_Summary.csv | 18 + .../tests/reports/Test_Execution_Log.csv | 243 +++ .../tests/reports/UC_Test_Design.csv | 58 + .../tests/reports/WF_Test_Design.csv | 17 + .../patent_system/tests/runner.py | 206 +++ .../patent_system/tests/specs/__init__.py | 1 + .../tests/specs/business_rules.yaml | 779 +++++++++ .../patent_system/tests/specs/use_cases.yaml | 536 ++++++ .../patent_system/tests/specs/workflows.yaml | 221 +++ .../tests/test_business_rules.py | 545 ++++++ .../patent_system/tests/test_module.py | 118 ++ .../patent_system/tests/test_use_cases.py | 887 ++++++++++ .../patent_system/tests/test_workflows.py | 236 +++ .../migrations/0026_add_database_indexes.py | 59 +- FusionIIIT/setup_tests.py | 287 +++ 40 files changed, 9473 insertions(+), 31 deletions(-) create mode 100644 FusionIIIT/applications/__init__.py create mode 100644 FusionIIIT/applications/patent_system/__init__.py create mode 100644 FusionIIIT/applications/patent_system/admin.py create mode 100644 FusionIIIT/applications/patent_system/api/__init__.py create mode 100644 FusionIIIT/applications/patent_system/api/serializers.py create mode 100644 FusionIIIT/applications/patent_system/api/urls.py create mode 100644 FusionIIIT/applications/patent_system/api/views.py create mode 100644 FusionIIIT/applications/patent_system/apps.py create mode 100644 FusionIIIT/applications/patent_system/migrations/0001_initial.py create mode 100644 FusionIIIT/applications/patent_system/migrations/0002_auto_20260221_1708.py create mode 100644 FusionIIIT/applications/patent_system/migrations/0003_auto_20260325_0314.py create mode 100644 FusionIIIT/applications/patent_system/migrations/0004_auto_20260325_1810.py create mode 100644 FusionIIIT/applications/patent_system/migrations/__init__.py create mode 100644 FusionIIIT/applications/patent_system/models.py create mode 100644 FusionIIIT/applications/patent_system/selectors.py create mode 100644 FusionIIIT/applications/patent_system/services.py create mode 100644 FusionIIIT/applications/patent_system/tests/__init__.py create mode 100644 FusionIIIT/applications/patent_system/tests/base.py create mode 100644 FusionIIIT/applications/patent_system/tests/conftest.py create mode 100644 FusionIIIT/applications/patent_system/tests/reports/Artifact_Evaluation.csv create mode 100644 FusionIIIT/applications/patent_system/tests/reports/BR_Test_Design.csv create mode 100644 FusionIIIT/applications/patent_system/tests/reports/Defect_Log.csv create mode 100644 FusionIIIT/applications/patent_system/tests/reports/Module_Test_Summary.csv create mode 100644 FusionIIIT/applications/patent_system/tests/reports/Test_Execution_Log.csv create mode 100644 FusionIIIT/applications/patent_system/tests/reports/UC_Test_Design.csv create mode 100644 FusionIIIT/applications/patent_system/tests/reports/WF_Test_Design.csv create mode 100644 FusionIIIT/applications/patent_system/tests/runner.py create mode 100644 FusionIIIT/applications/patent_system/tests/specs/__init__.py create mode 100644 FusionIIIT/applications/patent_system/tests/specs/business_rules.yaml create mode 100644 FusionIIIT/applications/patent_system/tests/specs/use_cases.yaml create mode 100644 FusionIIIT/applications/patent_system/tests/specs/workflows.yaml create mode 100644 FusionIIIT/applications/patent_system/tests/test_business_rules.py create mode 100644 FusionIIIT/applications/patent_system/tests/test_module.py create mode 100644 FusionIIIT/applications/patent_system/tests/test_use_cases.py create mode 100644 FusionIIIT/applications/patent_system/tests/test_workflows.py create mode 100644 FusionIIIT/setup_tests.py diff --git a/FusionIIIT/Fusion/settings/common.py b/FusionIIIT/Fusion/settings/common.py index bc97f1548..1d329cc75 100644 --- a/FusionIIIT/Fusion/settings/common.py +++ b/FusionIIIT/Fusion/settings/common.py @@ -141,6 +141,7 @@ 'applications.hr2', 'applications.department', 'applications.iwdModuleV2', + 'applications.patent_system', 'allauth', 'allauth.account', 'allauth.socialaccount', diff --git a/FusionIIIT/Fusion/settings/development.py b/FusionIIIT/Fusion/settings/development.py index 63587a11f..975306ec0 100644 --- a/FusionIIIT/Fusion/settings/development.py +++ b/FusionIIIT/Fusion/settings/development.py @@ -11,6 +11,7 @@ 'ENGINE': 'django.db.backends.postgresql_psycopg2', 'NAME': 'fusionlab', 'HOST': os.environ.get("DB_HOST", default='localhost'), + 'PORT': '5433', 'USER': 'fusion_admin', 'PASSWORD': 'hello123', } @@ -64,4 +65,13 @@ ('0 22 * * *', 'applications.central_mess.tasks.generate_bill'), ] -CRONTAB_DJANGO_MANAGE_PATH = '/home/owlman/Desktop/Fuse/Fusion/FusionIIIT/manage.py' \ No newline at end of file +CRONTAB_DJANGO_MANAGE_PATH = '/home/owlman/Desktop/Fuse/Fusion/FusionIIIT/manage.py' + +TEST_RUNNER = 'django.test.runner.DiscoverRunner' + +DATABASES['default']['TEST'] = { + 'MIRROR': 'default', +} + +DEBUG = True +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' \ No newline at end of file diff --git a/FusionIIIT/Fusion/urls.py b/FusionIIIT/Fusion/urls.py index e3b3f6792..375e62772 100755 --- a/FusionIIIT/Fusion/urls.py +++ b/FusionIIIT/Fusion/urls.py @@ -21,6 +21,8 @@ from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth import views as auth_views + +from django.views.static import serve from django.urls import path from applications.globals.views import RateLimitedPasswordResetView @@ -40,6 +42,8 @@ url(r'^__debug__/', include(debug_toolbar.urls)), url(r'^research_procedures/', include('applications.research_procedures.urls')), url(r'^accounts/', include('allauth.urls')), + + # url(r'^api/iwdModuleV2/', include('applications.iwdModuleV2.api.urls')), url(r'^eis/', include('applications.eis.urls')), @@ -55,6 +59,7 @@ url(r'^finance/', include('applications.finance_accounts.urls')), url(r'^purchase-and-store/', include('applications.ps1.urls')), url(r'^gymkhana/', include('applications.gymkhana.urls')), + # url(r'^inventory/', include('applications.inventory.urls')), # Commented out - module not present url(r'^library/', include('applications.library.urls')), url(r'^establishment/', include('applications.establishment.urls')), url(r'^ocms/', include('applications.online_cms.urls')), @@ -65,7 +70,10 @@ url(r'^recruitment/', include('applications.recruitment.urls')), url(r'^examination/', include('applications.examination.urls')), url(r'^otheracademic/', include('applications.otheracademic.urls')), - + url(r'^patentsystem/', include('applications.patent_system.api.urls')), + url(r'^media/(?P.*)$', serve, {"document_root": settings.MEDIA_ROOT},), + + # Password Reset URLs path( 'password-reset/', RateLimitedPasswordResetView.as_view( diff --git a/FusionIIIT/applications/__init__.py b/FusionIIIT/applications/__init__.py new file mode 100644 index 000000000..e843fc214 --- /dev/null +++ b/FusionIIIT/applications/__init__.py @@ -0,0 +1 @@ +# Package marker\n \ No newline at end of file diff --git a/FusionIIIT/applications/patent_system/__init__.py b/FusionIIIT/applications/patent_system/__init__.py new file mode 100644 index 000000000..e843fc214 --- /dev/null +++ b/FusionIIIT/applications/patent_system/__init__.py @@ -0,0 +1 @@ +# Package marker\n \ No newline at end of file diff --git a/FusionIIIT/applications/patent_system/admin.py b/FusionIIIT/applications/patent_system/admin.py new file mode 100644 index 000000000..9e808b1fb --- /dev/null +++ b/FusionIIIT/applications/patent_system/admin.py @@ -0,0 +1,85 @@ +from django.contrib import admin +from .models import ( + Applicant, Application, ApplicationSectionI, ApplicationSectionII, + ApplicationSectionIII, Inventor, CommunicationLog, Budget, AuditLog, Document, + AttorneyAssignment, PatentabilityAssessment, FilingRecord, +) + + +class InventorInline(admin.TabularInline): + model = Inventor + extra = 0 + + +class SectionIInline(admin.StackedInline): + model = ApplicationSectionI + extra = 0 + + +class SectionIIInline(admin.StackedInline): + model = ApplicationSectionII + extra = 0 + + +class SectionIIIInline(admin.TabularInline): + model = ApplicationSectionIII + extra = 0 + + +@admin.register(Application) +class ApplicationAdmin(admin.ModelAdmin): + list_display = ("id", "title", "status", "decision_status", "primary_applicant", "submitted_date") + list_filter = ("status", "decision_status") + search_fields = ("title", "token_no") + inlines = [InventorInline, SectionIInline, SectionIIInline, SectionIIIInline] + + +@admin.register(Applicant) +class ApplicantAdmin(admin.ModelAdmin): + list_display = ("id", "name", "email", "user") + search_fields = ("name", "email") + + +@admin.register(CommunicationLog) +class CommunicationLogAdmin(admin.ModelAdmin): + list_display = ("id", "application", "direction", "subject", "logged_by", "created_at") + list_filter = ("direction",) + + +@admin.register(Budget) +class BudgetAdmin(admin.ModelAdmin): + list_display = ("id", "application", "total_cost", "decision") + list_filter = ("decision",) + + +@admin.register(AuditLog) +class AuditLogAdmin(admin.ModelAdmin): + list_display = ("id", "application", "action", "user", "timestamp") + list_filter = ("action",) + readonly_fields = ("application", "user", "action", "previous_state", "new_state", "details", "timestamp") + + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ("id", "title", "link", "created_at") + + +@admin.register(AttorneyAssignment) +class AttorneyAssignmentAdmin(admin.ModelAdmin): + list_display = ("id", "application", "attorney_name", "attorney_firm", "assigned_by", "assignment_date", "is_active") + list_filter = ("is_active",) + search_fields = ("attorney_name", "attorney_firm", "attorney_email") + + +@admin.register(PatentabilityAssessment) +class PatentabilityAssessmentAdmin(admin.ModelAdmin): + list_display = ("id", "application", "assessed_by_attorney", "recommendation", "assessment_date") + list_filter = ("recommendation",) + search_fields = ("assessed_by_attorney", "opinion_summary") + + +@admin.register(FilingRecord) +class FilingRecordAdmin(admin.ModelAdmin): + list_display = ("id", "application", "filing_office", "jurisdiction", "external_filing_id", "filing_date") + list_filter = ("filing_office", "jurisdiction") + search_fields = ("external_filing_id",) diff --git a/FusionIIIT/applications/patent_system/api/__init__.py b/FusionIIIT/applications/patent_system/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/patent_system/api/serializers.py b/FusionIIIT/applications/patent_system/api/serializers.py new file mode 100644 index 000000000..339a2be52 --- /dev/null +++ b/FusionIIIT/applications/patent_system/api/serializers.py @@ -0,0 +1,325 @@ +""" +Patent Management System — DRF Serializers + field-level validation. +""" + +from rest_framework import serializers +from ..models import ( + Application, ApplicationSectionI, ApplicationSectionII, + ApplicationSectionIII, Applicant, Inventor, + CommunicationLog, Budget, AuditLog, Document, + AttorneyAssignment, PatentabilityAssessment, FilingRecord, + PatentNotification, ApplicationDocument, +) + + +# --------------------------------------------------------------------------- +# Applicant +# --------------------------------------------------------------------------- + +class ApplicantSerializer(serializers.ModelSerializer): + class Meta: + model = Applicant + fields = ["id", "name", "email", "mobile", "address"] + read_only_fields = ["id"] + + +# --------------------------------------------------------------------------- +# Inventor +# --------------------------------------------------------------------------- + +class InventorSerializer(serializers.ModelSerializer): + applicant_name = serializers.CharField(source="applicant.name", read_only=True) + applicant_email = serializers.CharField(source="applicant.email", read_only=True) + + class Meta: + model = Inventor + fields = ["id", "applicant", "applicant_name", "applicant_email", "percentage_share"] + read_only_fields = ["id"] + + +# --------------------------------------------------------------------------- +# Sections +# --------------------------------------------------------------------------- + +class SectionISerializer(serializers.ModelSerializer): + class Meta: + model = ApplicationSectionI + fields = [ + "id", "type_of_ip", "area", "problem", "objective", + "novelty", "advantages", "is_tested", "poc_details", "applications", + ] + read_only_fields = ["id"] + + +class SectionIISerializer(serializers.ModelSerializer): + class Meta: + model = ApplicationSectionII + fields = [ + "id", "funding_details", "funding_source", "source_agreement", + "publication_details", "mou_details", "mou_file", "research_details", + ] + read_only_fields = ["id"] + + +class SectionIIISerializer(serializers.ModelSerializer): + class Meta: + model = ApplicationSectionIII + fields = [ + "id", "company_name", "contact_person", "contact_no", + "development_stage", "form_iii", + ] + read_only_fields = ["id"] + + +# --------------------------------------------------------------------------- +# Application (list + detail) +# --------------------------------------------------------------------------- + +class ApplicationListSerializer(serializers.ModelSerializer): + primary_applicant_name = serializers.CharField(source="primary_applicant.name", read_only=True) + + class Meta: + model = Application + fields = [ + "id", "title", "status", "decision_status", "token_no", + "submitted_date", "primary_applicant_name", + ] + + +class ApplicationDetailSerializer(serializers.ModelSerializer): + section_i = SectionISerializer(source="section_i", read_only=True) + section_ii = SectionIISerializer(source="section_ii", read_only=True) + section_iii = SectionIIISerializer(source="section_iii", many=True, read_only=True) + inventors = InventorSerializer(many=True, read_only=True) + primary_applicant_name = serializers.CharField(source="primary_applicant.name", read_only=True) + + class Meta: + model = Application + fields = [ + "id", "title", "status", "decision_status", "token_no", + "comments", "director_feedback", + "submitted_date", "reviewed_by_pcc_date", + "forwarded_to_director_date", "director_approval_date", + "patentability_check_start_date", "patentability_check_completed_date", + "search_report_generated_date", "patent_filed_date", + "patent_published_date", "decision_date", + "withdrawn_date", "resubmission_deadline", + "last_updated_at", "created_at", + "primary_applicant_name", + "section_i", "section_ii", "section_iii", "inventors", + ] + + +# --------------------------------------------------------------------------- +# Communication Log (replaces Attorney) +# --------------------------------------------------------------------------- + +class CommunicationLogSerializer(serializers.ModelSerializer): + logged_by_name = serializers.CharField(source="logged_by.get_full_name", read_only=True) + + class Meta: + model = CommunicationLog + fields = [ + "id", "application", "logged_by", "logged_by_name", + "direction", "subject", "body", + "external_party_name", "external_party_email", + "attachment", "confidentiality_level", "created_at", + ] + read_only_fields = ["id", "logged_by", "logged_by_name", "created_at"] + + def validate_subject(self, value): + if not value or len(value.strip()) < 3: + raise serializers.ValidationError("Subject must be at least 3 characters.") + return value + + def validate_direction(self, value): + if value not in ("Incoming", "Outgoing"): + raise serializers.ValidationError("Direction must be 'Incoming' or 'Outgoing'.") + return value + + +# --------------------------------------------------------------------------- +# Budget +# --------------------------------------------------------------------------- + +class BudgetSerializer(serializers.ModelSerializer): + class Meta: + model = Budget + fields = [ + "id", "application", "filing_cost", "attorney_fees", + "administrative_cost", "total_cost", "decision", + "decision_by", "decision_date", "remarks", + "created_at", "updated_at", + ] + read_only_fields = ["id", "total_cost", "decision_by", "decision_date", "created_at", "updated_at"] + + +# --------------------------------------------------------------------------- +# Audit Log +# --------------------------------------------------------------------------- + +class AuditLogSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source="user.get_full_name", read_only=True) + + class Meta: + model = AuditLog + fields = [ + "id", "application", "user", "user_name", + "action", "previous_state", "new_state", + "details", "timestamp", + ] + read_only_fields = fields + + +# --------------------------------------------------------------------------- +# Document +# --------------------------------------------------------------------------- + +class DocumentSerializer(serializers.ModelSerializer): + class Meta: + model = Document + fields = ["id", "title", "link", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at"] + + def validate_title(self, value): + if not value or len(value.strip()) < 2: + raise serializers.ValidationError("Title is required.") + return value + + def validate_link(self, value): + if not value: + raise serializers.ValidationError("Link is required.") + return value + + +# --------------------------------------------------------------------------- +# Attorney Assignment (UC-006, BR-PMS-007) +# --------------------------------------------------------------------------- + +class AttorneyAssignmentSerializer(serializers.ModelSerializer): + assigned_by_name = serializers.CharField(source="assigned_by.get_full_name", read_only=True) + + class Meta: + model = AttorneyAssignment + fields = [ + "id", "application", "attorney_name", "attorney_email", + "attorney_phone", "attorney_firm", "specialization", + "assigned_by", "assigned_by_name", "assignment_date", + "engagement_proof", "remarks", "is_active", + ] + read_only_fields = ["id", "assigned_by", "assigned_by_name", "assignment_date"] + + def validate_attorney_name(self, value): + if not value or len(value.strip()) < 2: + raise serializers.ValidationError("Attorney name must be at least 2 characters.") + return value + + +# --------------------------------------------------------------------------- +# Patentability Assessment (UC-007, BR-PMS-014) +# --------------------------------------------------------------------------- + +class PatentabilityAssessmentSerializer(serializers.ModelSerializer): + recorded_by_name = serializers.CharField(source="recorded_by.get_full_name", read_only=True) + + class Meta: + model = PatentabilityAssessment + fields = [ + "id", "application", "assessed_by_attorney", + "novelty_score", "non_obviousness_score", + "utility_score", "search_completeness", + "recommendation", "opinion_summary", + "prior_art_references", "attorney_report", + "recorded_by", "recorded_by_name", + "assessment_date", "created_at", "updated_at", + ] + read_only_fields = [ + "id", "recorded_by", "recorded_by_name", + "created_at", "updated_at", + ] + + def validate_opinion_summary(self, value): + if not value or len(value.strip()) < 20: + raise serializers.ValidationError( + "Opinion summary must be at least 20 characters (BR-PMS-014)." + ) + return value + + +# --------------------------------------------------------------------------- +# Filing Record (UC-009, BR-PMS-017, WF-601) +# --------------------------------------------------------------------------- + +class FilingRecordSerializer(serializers.ModelSerializer): + filed_by_name = serializers.CharField(source="filed_by.get_full_name", read_only=True) + + class Meta: + model = FilingRecord + fields = [ + "id", "application", "filing_office", "jurisdiction", + "external_filing_id", "filing_date", + "confirmation_proof", "international_filing_justification", + "filed_by", "filed_by_name", "remarks", + "created_at", "updated_at", + ] + read_only_fields = [ + "id", "filed_by", "filed_by_name", + "created_at", "updated_at", + ] + + +# --------------------------------------------------------------------------- +# Feature 2: Inventor with Consent +# --------------------------------------------------------------------------- + +class InventorWithConsentSerializer(serializers.ModelSerializer): + applicant_name = serializers.CharField(source="applicant.name", read_only=True) + applicant_email = serializers.CharField(source="applicant.email", read_only=True) + + class Meta: + model = Inventor + fields = [ + "id", "applicant", "applicant_name", "applicant_email", + "percentage_share", "has_consent", "consent_date", + ] + read_only_fields = ["id", "has_consent", "consent_date"] + + +# --------------------------------------------------------------------------- +# Feature 4: Patent Notification +# --------------------------------------------------------------------------- + +class PatentNotificationSerializer(serializers.ModelSerializer): + application_title = serializers.CharField(source="application.title", read_only=True) + + class Meta: + model = PatentNotification + fields = [ + "id", "recipient", "application", "application_title", + "notification_type", "title", "message", + "is_read", "deadline_date", "action_url", "created_at", + ] + read_only_fields = fields + + +# --------------------------------------------------------------------------- +# Feature 5: Application Document (Version Control) +# --------------------------------------------------------------------------- + +class ApplicationDocumentSerializer(serializers.ModelSerializer): + uploaded_by_name = serializers.CharField(source="uploaded_by.get_full_name", read_only=True) + file_url = serializers.SerializerMethodField() + + class Meta: + model = ApplicationDocument + fields = [ + "id", "application", "document_type", "title", + "file", "file_url", "version", "description", + "uploaded_by", "uploaded_by_name", "is_current", "created_at", + ] + read_only_fields = ["id", "version", "is_current", "uploaded_by", "uploaded_by_name", "created_at"] + + def get_file_url(self, obj): + if obj.file: + return obj.file.url + return None diff --git a/FusionIIIT/applications/patent_system/api/urls.py b/FusionIIIT/applications/patent_system/api/urls.py new file mode 100644 index 000000000..ea92c720c --- /dev/null +++ b/FusionIIIT/applications/patent_system/api/urls.py @@ -0,0 +1,90 @@ +""" +Patent Management System — API URL routing. +""" + +from django.urls import path +from . import views + +urlpatterns = [ + # ── Applicant ────────────────────────────────────────────────────────── + path("applicant/applications/submit/", views.submit_application, name="pms_submit"), + path("applicant/applications/", views.view_applications, name="pms_applicant_list"), + path("applicant/applications/details//", views.view_application_details_for_applicant, name="pms_applicant_detail"), + path("applicant/applications/pending-consent/", views.view_pending_consent_applications, name="pms_pending_consent"), + path("applicant/applications/resubmit//", views.resubmit_application, name="pms_resubmit"), + path("applicant/applications/withdraw//", views.withdraw_application, name="pms_withdraw"), + path("applicant/drafts/", views.saved_drafts, name="pms_drafts"), + + # Feature 1: Appeal endpoints (Applicant) + path("applicant/applications//appeal/", views.lodge_appeal, name="pms_lodge_appeal"), + + # Feature 2: Consent endpoints (Applicant/Inventor) + path("applicant/applications//consent/", views.give_inventor_consent, name="pms_give_consent"), + path("applicant/applications//consent/revoke/", views.revoke_consent, name="pms_revoke_consent"), + path("applicant/applications//consent/status/", views.get_consent_status, name="pms_consent_status"), + + # ── PCC Admin ────────────────────────────────────────────────────────── + path("pccAdmin/applications/new/", views.new_applications, name="pms_pcc_new"), + path("pccAdmin/applications/new/review//", views.review_application, name="pms_pcc_review"), + path("pccAdmin/applications/new/forward//", views.forward_application, name="pms_pcc_forward"), + path("pccAdmin/applications/new/requestModification//", views.request_application_modification, name="pms_pcc_modify"), + path("pccAdmin/applications/ongoing/", views.ongoing_applications, name="pms_pcc_ongoing"), + path("pccAdmin/applications/ongoing/changeStatus//", views.change_application_status, name="pms_pcc_change_status"), + path("pccAdmin/applications/past/", views.past_applications, name="pms_pcc_past"), + path("pccAdmin/applications/details//", views.view_application_details_for_pccAdmin, name="pms_pcc_detail"), + + # Feature 1: Appeal endpoints (PCC Admin) + path("pccAdmin/applications//appeal/review/", views.pcc_review_appeal, name="pms_pcc_review_appeal"), + + # Communication logs (replaces attorney management) + path("pccAdmin/applications//communications/", views.communication_logs, name="pms_comm_logs"), + + # Budget + path("pccAdmin/applications//budget/", views.budget_view, name="pms_budget"), + + # Attorney Assignment (UC-006, BR-PMS-007 — PCC Admin assigns external attorney) + path("pccAdmin/applications//attorney/", views.attorney_assignment_view, name="pms_attorney"), + + # Patentability Assessment (UC-007, BR-PMS-014 — PCC Admin records attorney opinion) + path("pccAdmin/applications//assessment/", views.patentability_assessment_view, name="pms_assessment"), + + # Filing Record (UC-009, BR-PMS-017, WF-601 — PCC Admin logs filing with patent office) + path("pccAdmin/applications//filing/", views.filing_record_view, name="pms_filing"), + + # Feature 5: Document version control + path("pccAdmin/applications//documents/", views.application_documents, name="pms_documents_version"), + + # Audit + path("pccAdmin/applications//audit/", views.audit_logs, name="pms_audit"), + + # Analytics + path("pccAdmin/analytics/", views.analytics, name="pms_analytics"), + path("pccAdmin/analytics/summary/", views.analytics_summary, name="pms_analytics_summary"), + path("pccAdmin/departments/", views.get_departments, name="pms_departments"), + + # Feature 6: Search + path("search/", views.search_applications, name="pms_search"), + + # ── Director ─────────────────────────────────────────────────────────── + path("director/applications/new/", views.director_new_applications, name="pms_dir_new"), + path("director/application/accept", views.director_accept, name="pms_dir_accept"), + path("director/application/reject", views.director_reject, name="pms_dir_reject"), + path("director/reviewedapplications", views.director_reviewed_applications, name="pms_dir_reviewed"), + path("director/active", views.active_applications, name="pms_dir_active"), + path("director/application/details", views.director_application_view, name="pms_dir_detail"), + path("director/notifications/", views.director_notifications, name="pms_dir_notif"), + path("director/budget//decision/", views.director_budget_decision, name="pms_dir_budget"), + + # Feature 1: Appeal endpoints (Director) + path("director/applications//appeal/decision/", views.director_appeal_decision, name="pms_dir_appeal_decision"), + + # ── Notifications (Feature 4) ───────────────────────────────────────── + path("notifications/", views.get_notifications, name="pms_notifications"), + path("notifications//read/", views.mark_notification_read, name="pms_notification_read"), + path("notifications/read-all/", views.mark_all_notifications_read, name="pms_notifications_read_all"), + path("notifications/unread-count/", views.get_unread_count, name="pms_notifications_count"), + + # ── Documents (shared) ──────────────────────────────────────────────── + path("documents/", views.manage_documents, name="pms_documents"), + path("pccAdmin/documents//delete/", views.delete_document, name="pms_doc_delete"), +] diff --git a/FusionIIIT/applications/patent_system/api/views.py b/FusionIIIT/applications/patent_system/api/views.py new file mode 100644 index 000000000..218df4807 --- /dev/null +++ b/FusionIIIT/applications/patent_system/api/views.py @@ -0,0 +1,887 @@ +""" +Patent Management System — API Views (thin). +Each view delegates to services.py (writes) or selectors.py (reads). +""" + +import json +import logging + +from django.http import JsonResponse +from django.utils.timezone import now + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.authentication import TokenAuthentication +from rest_framework.decorators import ( + api_view, permission_classes, authentication_classes, +) + +from ..models import ApplicationStatus, Document, PatentNotification, ApplicationDocument +from .. import services, selectors +from .serializers import ( + DocumentSerializer, CommunicationLogSerializer, + BudgetSerializer, AuditLogSerializer, + AttorneyAssignmentSerializer, PatentabilityAssessmentSerializer, + FilingRecordSerializer, PatentNotificationSerializer, ApplicationDocumentSerializer, +) + +logger = logging.getLogger(__name__) + +# Shared decorator stack +_auth = [api_view, permission_classes, authentication_classes] + + +def _service_response(func, *args, **kwargs): + """Call a service function and convert PatentServiceError → JsonResponse.""" + try: + return func(*args, **kwargs) + except services.PatentServiceError as e: + return JsonResponse({"error": e.message}, status=e.code) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON."}, status=400) + except Exception as e: + logger.exception("Unhandled error") + return JsonResponse({"error": str(e)}, status=500) + + +# ========================================================================= +# APPLICANT VIEWS +# ========================================================================= + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def submit_application(request): + """UC-001 — Submit patent application.""" + def _do(): + json_data = request.POST.get("json_data") + if not json_data: + return JsonResponse({"error": "Missing json_data"}, status=400) + data = json.loads(json_data) + app = services.submit_application(request.user, data, request.FILES) + return JsonResponse({ + "message": "Application submitted successfully.", + "application_id": app.id, + }, status=201) + return _service_response(_do) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def view_applications(request): + """UC-005 — Applicant views own applications.""" + data = selectors.get_applicant_applications(request.user) + return JsonResponse({"applications": data}, safe=False) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def view_application_details_for_applicant(request, application_id): + """UC-003 — Applicant views single application detail.""" + detail = selectors.get_applicant_application_detail(request.user, application_id) + if detail is None: + return JsonResponse({"error": "Not found or not authorized."}, status=403) + return JsonResponse(detail, safe=False) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def view_pending_consent_applications(request): + """Get applications where current user is an inventor and consent is pending.""" + data = selectors.get_pending_consent_applications(request.user) + return JsonResponse(data, safe=False) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def give_inventor_consent(request, application_id): + """Inventor gives consent for an application.""" + def _do(): + try: + inventor = services.give_inventor_consent(request.user, application_id) + return JsonResponse({ + "message": "Consent given successfully.", + "consent_date": inventor.consent_date.isoformat() if inventor.consent_date else None, + }) + except Exception as e: + return JsonResponse({"error": str(e)}, status=400) + + return _do() + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def resubmit_application(request, application_id): + """UC-004 — Applicant resubmits after revision.""" + def _do(): + json_data = request.POST.get("json_data", "{}") + data = json.loads(json_data) + app = services.resubmit_application(request.user, application_id, data, request.FILES) + return JsonResponse({ + "message": "Application resubmitted.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def withdraw_application(request, application_id): + """UC-014 — Applicant withdraws application.""" + def _do(): + data = json.loads(request.body or "{}") + reason = data.get("reason", "") + app = services.withdraw_application(request.user, application_id, reason) + return JsonResponse({ + "message": "Application withdrawn.", + "application_id": app.id, + }) + return _service_response(_do) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def saved_drafts(request): + """UC-004 old — placeholder for saved drafts.""" + return JsonResponse({"message": "No saved drafts."}) + + +# ========================================================================= +# PCC ADMIN VIEWS +# ========================================================================= + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def new_applications(request): + """UC-005 old — PCC Admin views new / resubmitted applications.""" + return JsonResponse({"applications": selectors.get_new_applications_pcc()}, safe=False) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def review_application(request, application_id): + """UC-006 — PCC Admin marks reviewed.""" + def _do(): + data = json.loads(request.body or "{}") + comments = data.get("comments", "") + app = services.pcc_review_application(request.user, application_id, comments) + return JsonResponse({ + "message": "Application reviewed.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def forward_application(request, application_id): + """UC-007 — PCC Admin forwards to Director.""" + def _do(): + data = json.loads(request.body or "{}") + comments = data.get("comments", "") + app = services.forward_to_director(request.user, application_id, comments) + return JsonResponse({ + "message": "Application forwarded to Director.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def request_application_modification(request, application_id): + """UC-008 old — PCC Admin sends back to draft.""" + def _do(): + data = json.loads(request.body or "{}") + comments = data.get("comments", "") + app = services.request_modification(request.user, application_id, comments) + return JsonResponse({ + "message": "Modification requested.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def ongoing_applications(request): + return JsonResponse({"applications": selectors.get_ongoing_applications_pcc()}, safe=False) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def change_application_status(request, application_id): + """PCC Admin advances status.""" + def _do(): + data = json.loads(request.body or "{}") + next_status = data.get("next_status", "").strip() + if not next_status: + return JsonResponse({"error": "next_status is required."}, status=400) + app = services.change_status(request.user, application_id, next_status) + return JsonResponse({ + "message": f"Status changed to '{app.status}'.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def past_applications(request): + return JsonResponse({"applications": selectors.get_past_applications_pcc()}, safe=False) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def view_application_details_for_pccAdmin(request, application_id): + detail = selectors.get_pcc_application_detail(application_id) + return JsonResponse(detail, safe=False) + + +# ── Communication Log (replaces Attorney Management) ───────────────────── + +@api_view(["GET", "POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def communication_logs(request, application_id): + """ + GET — list logs for an application. + POST — add a new communication log entry (with optional attachment). + """ + if request.method == "GET": + logs = selectors.get_communication_logs(application_id) + serializer = CommunicationLogSerializer(logs, many=True) + return Response(serializer.data) + + # POST + def _do(): + app = services.add_communication_log( + user=request.user, + application_id=application_id, + direction=request.POST.get("direction", request.data.get("direction", "")), + subject=request.POST.get("subject", request.data.get("subject", "")), + body=request.POST.get("body", request.data.get("body", "")), + external_party_name=request.POST.get("external_party_name", request.data.get("external_party_name", "")), + external_party_email=request.POST.get("external_party_email", request.data.get("external_party_email", "")), + attachment=request.FILES.get("attachment"), + confidentiality_level=request.POST.get("confidentiality_level", request.data.get("confidentiality_level", "Internal")), + ) + return JsonResponse({ + "message": "Communication logged.", + "id": app.id, + }, status=201) + return _service_response(_do) + + +# ── Budget endpoints ───────────────────────────────────────────────────── + +@api_view(["GET", "POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def budget_view(request, application_id): + """GET — budget details; POST — create/update budget.""" + if request.method == "GET": + budget = selectors.get_budget(application_id) + if budget is None: + return JsonResponse({"budget": None}) + serializer = BudgetSerializer(budget) + return Response(serializer.data) + + def _do(): + data = json.loads(request.body or "{}") + budget = services.create_or_update_budget( + user=request.user, + application_id=application_id, + filing_cost=data.get("filing_cost", 0), + attorney_fees=data.get("attorney_fees", 0), + administrative_cost=data.get("administrative_cost", 0), + remarks=data.get("remarks", ""), + ) + return JsonResponse({ + "message": "Budget saved.", + "total_cost": str(budget.total_cost), + "decision": budget.decision, + }) + return _service_response(_do) + + +@api_view(["GET", "POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_budget_decision(request, application_id): + """Director views or decides on escalated budget.""" + if request.method == "GET": + # View budget details + budget = selectors.get_budget(application_id) + if not budget: + return JsonResponse({"error": "No budget found for this application."}, status=404) + serializer = BudgetSerializer(budget) + return Response(serializer.data) + + # POST - make decision + def _do(): + data = json.loads(request.body or "{}") + # Support both 'approve' boolean and 'decision' string + decision_str = data.get("decision", "").strip() + if decision_str: + approve = decision_str.lower() in ["approved", "approve", "yes"] + else: + approve = data.get("approve", False) + remarks = data.get("remarks", "") + budget = services.director_budget_decision(request.user, application_id, approve, remarks) + return JsonResponse({ + "message": f"Budget {'approved' if approve else 'denied'}.", + "decision": budget.decision, + }) + return _service_response(_do) + + +# ── Audit Logs ─────────────────────────────────────────────────────────── + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def audit_logs(request, application_id): + logs = selectors.get_audit_logs(application_id) + serializer = AuditLogSerializer(logs, many=True) + return Response(serializer.data) + + +# ── Analytics ──────────────────────────────────────────────────────────── + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def analytics(request): + """UC-015 — Application stats for dashboards.""" + year = request.GET.get("year") + stats = list(selectors.get_application_stats(year=year)) + available_years = selectors.get_available_years() + return JsonResponse({"stats": stats, "available_years": available_years}, safe=False) + + +# ========================================================================= +# DIRECTOR VIEWS +# ========================================================================= + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_new_applications(request): + return JsonResponse({"applications": selectors.get_director_new_applications()}, safe=False) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_accept(request): + """Director approves application.""" + def _do(): + data = json.loads(request.body or "{}") + app_id = data.get("application_id") + feedback = data.get("comments", data.get("feedback", "")) + if not app_id: + return JsonResponse({"error": "application_id required."}, status=400) + app = services.director_review(request.user, app_id, "Approve", feedback) + return JsonResponse({ + "message": "Application approved by Director.", + "application_id": app.id, + "token_no": app.token_no, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_reject(request): + """Director rejects or requests revision.""" + def _do(): + data = json.loads(request.body or "{}") + app_id = data.get("application_id") + feedback = data.get("comments", data.get("feedback", "")) + decision = data.get("decision", "Reject") # "Reject" or "Needs Revision" + if not app_id: + return JsonResponse({"error": "application_id required."}, status=400) + app = services.director_review(request.user, app_id, decision, feedback) + return JsonResponse({ + "message": f"Application {decision}.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_reviewed_applications(request): + return JsonResponse({"applications": selectors.get_director_reviewed_applications()}, safe=False) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def active_applications(request): + """Same as director reviewed but only pending decision.""" + return JsonResponse({"applications": selectors.get_director_reviewed_applications()}, safe=False) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_application_view(request): + """Director detail view — accepts application_id in body.""" + try: + data = json.loads(request.body or "{}") + app_id = data.get("application_id") + if not app_id: + return JsonResponse({"error": "application_id required."}, status=400) + detail = selectors.get_director_application_detail(app_id) + return JsonResponse(detail, safe=False) + except Exception as e: + return JsonResponse({"error": str(e)}, status=500) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_notifications(request): + return JsonResponse({"notifications": []}) + + +# ========================================================================= +# ATTORNEY ASSIGNMENT (UC-006, BR-PMS-007) — PCC Admin only +# ========================================================================= + +@api_view(["GET", "POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def attorney_assignment_view(request, application_id): + """ + GET — View current attorney assignment for an application. + POST — Assign/update external attorney details (PCC Admin only). + """ + if request.method == "GET": + assignment = selectors.get_attorney_assignment(application_id) + if assignment is None: + return JsonResponse({"attorney_assignment": None}) + serializer = AttorneyAssignmentSerializer(assignment) + return Response(serializer.data) + + # POST — assign attorney + def _do(): + assignment = services.assign_attorney( + user=request.user, + application_id=application_id, + attorney_name=request.POST.get("attorney_name", request.data.get("attorney_name", "")), + attorney_email=request.POST.get("attorney_email", request.data.get("attorney_email", "")), + attorney_phone=request.POST.get("attorney_phone", request.data.get("attorney_phone", "")), + attorney_firm=request.POST.get("attorney_firm", request.data.get("attorney_firm", "")), + specialization=request.POST.get("specialization", request.data.get("specialization", "")), + remarks=request.POST.get("remarks", request.data.get("remarks", "")), + engagement_proof=request.FILES.get("engagement_proof"), + ) + return JsonResponse({ + "message": "Attorney assigned successfully.", + "id": assignment.id, + "attorney_name": assignment.attorney_name, + }, status=201) + return _service_response(_do) + + +# ========================================================================= +# PATENTABILITY ASSESSMENT (UC-007, BR-PMS-014) — PCC Admin records +# ========================================================================= + +@api_view(["GET", "POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def patentability_assessment_view(request, application_id): + """ + GET — View current patentability assessment. + POST — Record/update the external attorney's assessment (PCC Admin only). + """ + if request.method == "GET": + assessment = selectors.get_patentability_assessment(application_id) + if assessment is None: + return JsonResponse({"patentability_assessment": None}) + serializer = PatentabilityAssessmentSerializer(assessment) + return Response(serializer.data) + + # POST — record assessment + def _do(): + data = request.data if hasattr(request, 'data') else {} + assessment = services.record_patentability_assessment( + user=request.user, + application_id=application_id, + recommendation=request.POST.get("recommendation", data.get("recommendation", "")), + opinion_summary=request.POST.get("opinion_summary", data.get("opinion_summary", "")), + novelty_score=request.POST.get("novelty_score", data.get("novelty_score", 0)), + non_obviousness_score=request.POST.get("non_obviousness_score", data.get("non_obviousness_score", 0)), + utility_score=request.POST.get("utility_score", data.get("utility_score", 0)), + search_completeness=request.POST.get("search_completeness", data.get("search_completeness", 0)), + prior_art_references=request.POST.get("prior_art_references", data.get("prior_art_references", "")), + assessed_by_attorney=request.POST.get("assessed_by_attorney", data.get("assessed_by_attorney", "")), + attorney_report=request.FILES.get("attorney_report"), + ) + return JsonResponse({ + "message": "Patentability assessment recorded.", + "id": assessment.id, + "recommendation": assessment.recommendation, + }, status=201) + return _service_response(_do) + + +# ========================================================================= +# FILING RECORD (UC-009, BR-PMS-017, WF-601) — PCC Admin logs filing +# ========================================================================= + +@api_view(["GET", "POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def filing_record_view(request, application_id): + """ + GET — View filing record for an application. + POST — Record/update filing details with patent office (PCC Admin only). + """ + if request.method == "GET": + filing = selectors.get_filing_record(application_id) + if filing is None: + return JsonResponse({"filing_record": None}) + serializer = FilingRecordSerializer(filing) + return Response(serializer.data) + + # POST — record filing + def _do(): + data = request.data if hasattr(request, 'data') else {} + filing = services.record_filing( + user=request.user, + application_id=application_id, + filing_office=request.POST.get("filing_office", data.get("filing_office", "Indian Patent Office")), + jurisdiction=request.POST.get("jurisdiction", data.get("jurisdiction", "India")), + external_filing_id=request.POST.get("external_filing_id", data.get("external_filing_id", "")), + international_filing_justification=request.POST.get( + "international_filing_justification", + data.get("international_filing_justification", ""), + ), + confirmation_proof=request.FILES.get("confirmation_proof"), + remarks=request.POST.get("remarks", data.get("remarks", "")), + ) + return JsonResponse({ + "message": "Filing recorded successfully.", + "id": filing.id, + "external_filing_id": filing.external_filing_id, + }, status=201) + return _service_response(_do) + + +# ========================================================================= +# DOCUMENT MANAGEMENT (shared) +# ========================================================================= + +@api_view(["GET", "POST"]) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def manage_documents(request): + if request.method == "GET": + docs = selectors.get_all_documents() + serializer = DocumentSerializer(docs, many=True) + return Response(serializer.data) + serializer = DocumentSerializer(data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@api_view(["DELETE"]) +@authentication_classes([TokenAuthentication]) +@permission_classes([IsAuthenticated]) +def delete_document(request, document_id): + doc = selectors.get_document_or_404(document_id) + doc.delete() + return Response({"message": "Document deleted."}, status=status.HTTP_200_OK) + + +# ========================================================================= +# FEATURE 1: APPEAL ENDPOINTS +# ========================================================================= + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def lodge_appeal(request, application_id): + """Applicant lodges a formal appeal against rejection.""" + def _do(): + data = json.loads(request.body or "{}") + reason = data.get("reason", "") + app = services.lodge_appeal(request.user, application_id, reason) + return JsonResponse({ + "message": "Appeal lodged successfully.", + "application_id": app.id, + "status": app.status, + }, status=201) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def pcc_review_appeal(request, application_id): + """PCC Admin forwards appeal to Director for review.""" + def _do(): + app = services.pcc_review_appeal(request.user, application_id) + return JsonResponse({ + "message": "Appeal forwarded to Director for review.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def director_appeal_decision(request, application_id): + """Director decides on the appeal.""" + def _do(): + data = json.loads(request.body or "{}") + approve = data.get("approve", False) + feedback = data.get("feedback", data.get("comments", "")) + app = services.director_appeal_decision(request.user, application_id, approve, feedback) + return JsonResponse({ + "message": f"Appeal {'approved' if approve else 'rejected'}.", + "application_id": app.id, + "status": app.status, + }) + return _service_response(_do) + + +# ========================================================================= +# FEATURE 2: INVENTOR CONSENT ENDPOINTS +# ========================================================================= + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def give_consent(request, application_id): + """Inventor gives consent for the application.""" + def _do(): + inventor = services.give_inventor_consent(request.user, application_id) + return JsonResponse({ + "message": "Consent given successfully.", + "has_consent": inventor.has_consent, + "consent_date": inventor.consent_date.isoformat() if inventor.consent_date else None, + }) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def revoke_consent(request, application_id): + """Inventor revokes consent for the application.""" + def _do(): + inventor = services.revoke_inventor_consent(request.user, application_id) + return JsonResponse({ + "message": "Consent revoked successfully.", + "has_consent": inventor.has_consent, + }) + return _service_response(_do) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def get_consent_status(request, application_id): + """Get consent status for all inventors on an application.""" + consent_info = selectors.get_inventors_consent_status(application_id) + return JsonResponse(consent_info, safe=False) + + +# ========================================================================= +# FEATURE 4: NOTIFICATION ENDPOINTS +# ========================================================================= + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def get_notifications(request): + """Get notifications for the current user.""" + unread_only = request.GET.get("unread_only", "false").lower() == "true" + notifications = services.get_user_notifications(request.user, unread_only) + serializer = PatentNotificationSerializer(notifications, many=True) + return Response(serializer.data) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def mark_notification_read(request, notification_id): + """Mark a notification as read.""" + def _do(): + services.mark_notification_read(request.user, notification_id) + return JsonResponse({"message": "Notification marked as read."}) + return _service_response(_do) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def mark_all_notifications_read(request): + """Mark all notifications as read.""" + count = services.mark_all_notifications_read(request.user) + return JsonResponse({"message": f"{count} notifications marked as read."}) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def get_unread_count(request): + """Get count of unread notifications.""" + count = PatentNotification.objects.filter(recipient=request.user, is_read=False).count() + return JsonResponse({"unread_count": count}) + + +# ========================================================================= +# FEATURE 5: DOCUMENT VERSION CONTROL ENDPOINTS +# ========================================================================= + +@api_view(["GET", "POST"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def application_documents(request, application_id): + """ + GET — Get all documents for an application (with version history). + POST — Upload a new document version. + """ + if request.method == "GET": + document_type = request.GET.get("document_type") + current_only = request.GET.get("current_only", "false").lower() == "true" + + if current_only: + docs = services.get_current_documents(application_id) + else: + docs = services.get_document_versions(application_id, document_type) + + serializer = ApplicationDocumentSerializer(docs, many=True) + return Response(serializer.data) + + # POST - upload new document + def _do(): + doc = services.upload_document( + user=request.user, + application_id=application_id, + document_type=request.POST.get("document_type", request.data.get("document_type", "")), + title=request.POST.get("title", request.data.get("title", "")), + file=request.FILES.get("file"), + description=request.POST.get("description", request.data.get("description", "")), + ) + return JsonResponse({ + "message": "Document uploaded successfully.", + "id": doc.id, + "version": doc.version, + "title": doc.title, + }, status=201) + return _service_response(_do) + + +# ========================================================================= +# FEATURE 6: SEARCH & GLOBAL FILTERING ENDPOINTS +# ========================================================================= + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def search_applications(request): + """Search and filter applications.""" + query = request.GET.get("q", "") + status_filter = request.GET.getlist("status") + decision_filter = request.GET.get("decision") + date_from = request.GET.get("date_from") + date_to = request.GET.get("date_to") + department = request.GET.get("department") + limit = int(request.GET.get("limit", 50)) + offset = int(request.GET.get("offset", 0)) + + # Parse dates + from datetime import datetime + if date_from: + try: + date_from = datetime.strptime(date_from, "%Y-%m-%d") + except ValueError: + date_from = None + if date_to: + try: + date_to = datetime.strptime(date_to, "%Y-%m-%d") + except ValueError: + date_to = None + + results = services.search_applications( + user=request.user, + query=query, + status_filter=status_filter if status_filter else None, + date_from=date_from, + date_to=date_to, + department_filter=department, + decision_filter=decision_filter, + limit=limit, + offset=offset, + ) + + return JsonResponse(results, safe=False) + + +# ========================================================================= +# FEATURE 7: ENHANCED ANALYTICS ENDPOINTS +# ========================================================================= + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def analytics_summary(request): + """Get comprehensive analytics summary.""" + year = request.GET.get("year") + department = request.GET.get("department") + + try: + if year: + year = int(year) + except ValueError: + year = None + + summary = services.get_analytics_summary(year=year, department=department) + return JsonResponse(summary, safe=False) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def get_departments(request): + """Get list of all departments for filtering.""" + from applications.globals.models import DepartmentInfo + departments = list(DepartmentInfo.objects.values_list("name", flat=True)) + return JsonResponse({"departments": departments}) diff --git a/FusionIIIT/applications/patent_system/apps.py b/FusionIIIT/applications/patent_system/apps.py new file mode 100644 index 000000000..44d004352 --- /dev/null +++ b/FusionIIIT/applications/patent_system/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class PatentSystemConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "applications.patent_system" + verbose_name = "Patent Management System" diff --git a/FusionIIIT/applications/patent_system/migrations/0001_initial.py b/FusionIIIT/applications/patent_system/migrations/0001_initial.py new file mode 100644 index 000000000..385efa971 --- /dev/null +++ b/FusionIIIT/applications/patent_system/migrations/0001_initial.py @@ -0,0 +1,198 @@ +# Generated by Django 3.1.5 on 2026-02-17 16:19 + +import applications.patent_system.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Applicant', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=254, unique=True)), + ('mobile', models.CharField(blank=True, default='', max_length=15)), + ('address', models.CharField(blank=True, default='', max_length=255)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='patent_applicant', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_applicant', + }, + ), + migrations.CreateModel( + name='Application', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('title', models.CharField(max_length=255)), + ('status', models.CharField(choices=[('Draft', 'Draft'), ('Submitted', 'Submitted'), ('Under Review', 'Under Review'), ('Reviewed by PCC Admin', 'Reviewed by PCC Admin'), ("Forwarded for Director's Review", "Forwarded for Director's Review"), ('Approved', 'Approved'), ('Needs Revision', 'Needs Revision'), ('Rejected', 'Rejected'), ('Resubmitted', 'Resubmitted'), ('Patentability Check Started', 'Patentability Check Started'), ('Patentability Check Completed', 'Patentability Check Completed'), ('Search Report Generated', 'Search Report Generated'), ('Patent Filed', 'Patent Filed'), ('Patent Published', 'Patent Published'), ('Patent Granted', 'Patent Granted'), ('Patent Refused', 'Patent Refused'), ('Withdrawn', 'Withdrawn'), ('Expired', 'Expired')], default='Draft', max_length=60)), + ('decision_status', models.CharField(choices=[('Pending', 'Pending'), ('Approved', 'Approved'), ('Rejected', 'Rejected'), ('Needs Revision', 'Needs Revision')], default='Pending', max_length=30)), + ('token_no', models.CharField(blank=True, max_length=120, null=True)), + ('comments', models.TextField(blank=True, default='')), + ('director_feedback', models.TextField(blank=True, default='')), + ('submitted_date', models.DateTimeField(blank=True, null=True)), + ('reviewed_by_pcc_date', models.DateTimeField(blank=True, null=True)), + ('forwarded_to_director_date', models.DateTimeField(blank=True, null=True)), + ('director_approval_date', models.DateTimeField(blank=True, null=True)), + ('patentability_check_start_date', models.DateTimeField(blank=True, null=True)), + ('patentability_check_completed_date', models.DateTimeField(blank=True, null=True)), + ('search_report_generated_date', models.DateTimeField(blank=True, null=True)), + ('patent_filed_date', models.DateTimeField(blank=True, null=True)), + ('patent_published_date', models.DateTimeField(blank=True, null=True)), + ('decision_date', models.DateTimeField(blank=True, null=True)), + ('withdrawn_date', models.DateTimeField(blank=True, null=True)), + ('resubmission_deadline', models.DateTimeField(blank=True, null=True)), + ('last_updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('assigned_director', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='directed_patent_apps', to=settings.AUTH_USER_MODEL)), + ('primary_applicant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='patent_system.applicant')), + ], + options={ + 'db_table': 'patent_system_application', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Document', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('link', models.URLField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'patent_system_document', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='CommunicationLog', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('direction', models.CharField(choices=[('Incoming', 'Incoming'), ('Outgoing', 'Outgoing')], max_length=10)), + ('subject', models.CharField(max_length=500)), + ('body', models.TextField(blank=True, default='')), + ('external_party_name', models.CharField(blank=True, default='', max_length=255)), + ('external_party_email', models.EmailField(blank=True, default='', max_length=254)), + ('attachment', models.FileField(blank=True, null=True, upload_to=applications.patent_system.models.communication_attachment_path)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='communications', to='patent_system.application')), + ('logged_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pcc_comm_logs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_communication_log', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Budget', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('filing_cost', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('attorney_fees', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('administrative_cost', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('total_cost', models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ('decision', models.CharField(choices=[('Pending', 'Pending'), ('Approved by PCC', 'Approved by PCC'), ('Escalated to Director', 'Escalated to Director'), ('Approved by Director', 'Approved by Director'), ('Denied', 'Denied')], default='Pending', max_length=30)), + ('decision_date', models.DateTimeField(blank=True, null=True)), + ('remarks', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='budget', to='patent_system.application')), + ('decision_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_budget', + }, + ), + migrations.CreateModel( + name='AuditLog', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('action', models.CharField(max_length=255)), + ('previous_state', models.CharField(blank=True, default='', max_length=60)), + ('new_state', models.CharField(blank=True, default='', max_length=60)), + ('details', models.TextField(blank=True, default='')), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='audit_logs', to='patent_system.application')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_audit_log', + 'ordering': ['-timestamp'], + }, + ), + migrations.CreateModel( + name='ApplicationSectionIII', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('company_name', models.CharField(max_length=255)), + ('contact_person', models.CharField(max_length=255)), + ('contact_no', models.CharField(max_length=15)), + ('development_stage', models.CharField(choices=[('Embryonic', 'Embryonic'), ('Partially developed', 'Partially developed'), ('Off-the-shelf', 'Off-the-shelf')], max_length=30)), + ('form_iii', models.FileField(blank=True, null=True, upload_to=applications.patent_system.models.form_iii_upload_path)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='section_iii', to='patent_system.application')), + ], + options={ + 'db_table': 'patent_system_application_section_iii', + }, + ), + migrations.CreateModel( + name='ApplicationSectionII', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('funding_details', models.TextField()), + ('funding_source', models.TextField()), + ('source_agreement', models.FileField(blank=True, null=True, upload_to=applications.patent_system.models.source_agreement_upload_path)), + ('publication_details', models.TextField()), + ('mou_details', models.TextField()), + ('mou_file', models.FileField(blank=True, null=True, upload_to=applications.patent_system.models.mou_file_upload_path)), + ('research_details', models.TextField()), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='section_ii', to='patent_system.application')), + ], + options={ + 'db_table': 'patent_system_application_section_ii', + }, + ), + migrations.CreateModel( + name='ApplicationSectionI', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('type_of_ip', models.CharField(choices=[('Patent', 'Patent'), ('Copyright', 'Copyright'), ('Trademark', 'Trademark'), ('Industrial Design', 'Industrial Design'), ('Trade Secret', 'Trade Secret'), ('Geographical Indication', 'Geographical Indication')], default='Patent', max_length=50)), + ('area', models.TextField()), + ('problem', models.TextField()), + ('objective', models.TextField()), + ('novelty', models.TextField()), + ('advantages', models.TextField()), + ('is_tested', models.BooleanField(default=False)), + ('poc_details', models.FileField(blank=True, null=True, upload_to=applications.patent_system.models.poc_file_upload_path)), + ('applications', models.TextField()), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='section_i', to='patent_system.application')), + ], + options={ + 'db_table': 'patent_system_application_section_i', + }, + ), + migrations.CreateModel( + name='Inventor', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('percentage_share', models.DecimalField(decimal_places=2, max_digits=5)), + ('applicant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventions', to='patent_system.applicant')), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventors', to='patent_system.application')), + ], + options={ + 'db_table': 'patent_system_inventor', + 'unique_together': {('applicant', 'application')}, + }, + ), + ] diff --git a/FusionIIIT/applications/patent_system/migrations/0002_auto_20260221_1708.py b/FusionIIIT/applications/patent_system/migrations/0002_auto_20260221_1708.py new file mode 100644 index 000000000..9ebe0ac85 --- /dev/null +++ b/FusionIIIT/applications/patent_system/migrations/0002_auto_20260221_1708.py @@ -0,0 +1,89 @@ +# Generated by Django 3.1.5 on 2026-02-21 17:08 + +import applications.patent_system.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('patent_system', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='document', + options={}, + ), + migrations.AddField( + model_name='communicationlog', + name='confidentiality_level', + field=models.CharField(choices=[('Public', 'Public'), ('Internal', 'Internal'), ('Confidential', 'Confidential'), ('Attorney-Client Privileged', 'Attorney-Client Privileged')], default='Internal', help_text='BR-PMS-019: Confidentiality marking for legal communications.', max_length=30), + ), + migrations.CreateModel( + name='PatentabilityAssessment', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('assessed_by_attorney', models.CharField(blank=True, default='', help_text='Name of the external attorney who performed the assessment.', max_length=255)), + ('novelty_score', models.DecimalField(decimal_places=2, default=0, help_text="Novelty score (0-100) as per attorney's evaluation.", max_digits=5)), + ('non_obviousness_score', models.DecimalField(decimal_places=2, default=0, help_text='Non-obviousness score (0-100).', max_digits=5)), + ('utility_score', models.DecimalField(decimal_places=2, default=0, help_text='Utility / industrial applicability score (0-100).', max_digits=5)), + ('search_completeness', models.DecimalField(decimal_places=2, default=0, help_text='Prior-art search completeness percentage (0-100).', max_digits=5)), + ('recommendation', models.CharField(choices=[('File Patent', 'File Patent'), ('Do Not File', 'Do Not File'), ('Needs Amendment', 'Needs Amendment')], default='File Patent', max_length=30)), + ('opinion_summary', models.TextField(blank=True, default='', help_text="Summary of the attorney's patentability opinion.")), + ('prior_art_references', models.TextField(blank=True, default='', help_text='Prior art references identified during the search.')), + ('attorney_report', models.FileField(blank=True, help_text='Full attorney report / opinion document.', null=True, upload_to=applications.patent_system.models.assessment_report_upload_path)), + ('assessment_date', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='patentability_assessment', to='patent_system.application')), + ('recorded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='recorded_assessments', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_patentability_assessment', + }, + ), + migrations.CreateModel( + name='FilingRecord', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('filing_office', models.CharField(default='Indian Patent Office', help_text='Name of the patent office where filed.', max_length=255)), + ('jurisdiction', models.CharField(blank=True, default='India', help_text='Filing jurisdiction (e.g. India, US, PCT).', max_length=100)), + ('external_filing_id', models.CharField(blank=True, default='', help_text='Official filing/application number from the patent office.', max_length=255)), + ('filing_date', models.DateTimeField(blank=True, help_text='Official date of filing as recorded by the patent office.', null=True)), + ('confirmation_proof', models.FileField(blank=True, help_text='Upload filing receipt, confirmation email screenshot, etc.', null=True, upload_to=applications.patent_system.models.filing_confirmation_upload_path)), + ('international_filing_justification', models.TextField(blank=True, default='', help_text='BR-PMS-017: Required justification if filing outside Indian Patent Office.')), + ('remarks', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='filing_record', to='patent_system.application')), + ('filed_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='filed_patents', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_filing_record', + }, + ), + migrations.CreateModel( + name='AttorneyAssignment', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('attorney_name', models.CharField(max_length=255)), + ('attorney_email', models.EmailField(blank=True, default='', max_length=254)), + ('attorney_phone', models.CharField(blank=True, default='', max_length=20)), + ('attorney_firm', models.CharField(blank=True, default='', max_length=255)), + ('specialization', models.CharField(blank=True, default='', help_text='Domain expertise of the attorney relevant to this application.', max_length=255)), + ('assignment_date', models.DateTimeField(auto_now_add=True)), + ('engagement_proof', models.FileField(blank=True, help_text='Upload engagement letter, email screenshot, or other proof of assignment.', null=True, upload_to=applications.patent_system.models.communication_attachment_path)), + ('remarks', models.TextField(blank=True, default='')), + ('is_active', models.BooleanField(default=True)), + ('application', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='attorney_assignment', to='patent_system.application')), + ('assigned_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='attorney_assignments_made', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_attorney_assignment', + }, + ), + ] diff --git a/FusionIIIT/applications/patent_system/migrations/0003_auto_20260325_0314.py b/FusionIIIT/applications/patent_system/migrations/0003_auto_20260325_0314.py new file mode 100644 index 000000000..e06658290 --- /dev/null +++ b/FusionIIIT/applications/patent_system/migrations/0003_auto_20260325_0314.py @@ -0,0 +1,96 @@ +# Generated by Django 3.1.5 on 2026-03-25 03:14 + +import applications.patent_system.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('patent_system', '0002_auto_20260221_1708'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='appeal_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='application', + name='appeal_decision', + field=models.CharField(blank=True, default='', max_length=60), + ), + migrations.AddField( + model_name='application', + name='appeal_decision_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='appeal_decisions', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='application', + name='appeal_decision_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='application', + name='appeal_reason', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='inventor', + name='consent_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='inventor', + name='has_consent', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='application', + name='status', + field=models.CharField(choices=[('Draft', 'Draft'), ('Submitted', 'Submitted'), ('Under Review', 'Under Review'), ('Reviewed by PCC Admin', 'Reviewed by PCC Admin'), ("Forwarded for Director's Review", "Forwarded for Director's Review"), ('Approved', 'Approved'), ('Needs Revision', 'Needs Revision'), ('Rejected', 'Rejected'), ('Resubmitted', 'Resubmitted'), ('Appeal', 'Appeal'), ('Appeal Under Review', 'Appeal Under Review'), ('Appeal Approved', 'Appeal Approved'), ('Appeal Rejected', 'Appeal Rejected'), ('Patentability Check Started', 'Patentability Check Started'), ('Patentability Check Completed', 'Patentability Check Completed'), ('Search Report Generated', 'Search Report Generated'), ('Patent Filed', 'Patent Filed'), ('Patent Published', 'Patent Published'), ('Patent Granted', 'Patent Granted'), ('Patent Refused', 'Patent Refused'), ('Withdrawn', 'Withdrawn'), ('Expired', 'Expired')], default='Draft', max_length=60), + ), + migrations.CreateModel( + name='PatentNotification', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('notification_type', models.CharField(choices=[('Deadline Approaching', 'Deadline Approaching'), ('Deadline Expired', 'Deadline Expired'), ('Status Change', 'Status Change'), ('Action Required', 'Action Required'), ('Appeal Update', 'Appeal Update'), ('Consent Required', 'Consent Required'), ('Revision Requested', 'Revision Requested'), ('Application Approved', 'Application Approved'), ('Application Rejected', 'Application Rejected')], max_length=50)), + ('title', models.CharField(max_length=255)), + ('message', models.TextField()), + ('is_read', models.BooleanField(default=False)), + ('deadline_date', models.DateTimeField(blank=True, null=True)), + ('action_url', models.CharField(blank=True, default='', max_length=500)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to='patent_system.application')), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='patent_notifications', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_notification', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ApplicationDocument', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('document_type', models.CharField(help_text='Type of document (e.g., POC, MOU, Form III, Supporting Doc)', max_length=100)), + ('title', models.CharField(max_length=255)), + ('file', models.FileField(upload_to=applications.patent_system.models.versioned_document_upload_path)), + ('version', models.PositiveIntegerField(default=1)), + ('description', models.TextField(blank=True, default='')), + ('is_current', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='patent_system.application')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'patent_system_application_document', + 'ordering': ['-version', '-created_at'], + 'unique_together': {('application', 'document_type', 'version')}, + }, + ), + ] diff --git a/FusionIIIT/applications/patent_system/migrations/0004_auto_20260325_1810.py b/FusionIIIT/applications/patent_system/migrations/0004_auto_20260325_1810.py new file mode 100644 index 000000000..077abc899 --- /dev/null +++ b/FusionIIIT/applications/patent_system/migrations/0004_auto_20260325_1810.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-25 18:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('patent_system', '0003_auto_20260325_0314'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='status', + field=models.CharField(choices=[('Draft', 'Draft'), ('Pending Inventor Consent', 'Pending Inventor Consent'), ('Submitted', 'Submitted'), ('Under Review', 'Under Review'), ('Reviewed by PCC Admin', 'Reviewed by PCC Admin'), ("Forwarded for Director's Review", "Forwarded for Director's Review"), ('Approved', 'Approved'), ('Needs Revision', 'Needs Revision'), ('Rejected', 'Rejected'), ('Resubmitted', 'Resubmitted'), ('Appeal', 'Appeal'), ('Appeal Under Review', 'Appeal Under Review'), ('Appeal Approved', 'Appeal Approved'), ('Appeal Rejected', 'Appeal Rejected'), ('Patentability Check Started', 'Patentability Check Started'), ('Patentability Check Completed', 'Patentability Check Completed'), ('Search Report Generated', 'Search Report Generated'), ('Patent Filed', 'Patent Filed'), ('Patent Published', 'Patent Published'), ('Patent Granted', 'Patent Granted'), ('Patent Refused', 'Patent Refused'), ('Withdrawn', 'Withdrawn'), ('Expired', 'Expired')], default='Draft', max_length=60), + ), + ] diff --git a/FusionIIIT/applications/patent_system/migrations/__init__.py b/FusionIIIT/applications/patent_system/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/patent_system/models.py b/FusionIIIT/applications/patent_system/models.py new file mode 100644 index 000000000..5993ba807 --- /dev/null +++ b/FusionIIIT/applications/patent_system/models.py @@ -0,0 +1,648 @@ +""" +Patent Management System — Models +Aligned with New BRs (BR-PMS-001 → 019), UCs (PMS-UC-001 → 020), WFs (PMS-WF-101 → 601). +Attorney role removed: PCC_ADMIN performs all attorney duties and logs external communications. +""" + +import os +from django.db import models +from django.contrib.auth.models import User +from django.utils.timezone import now + + +# --------------------------------------------------------------------------- +# Choices (TextChoices / IntegerChoices) +# --------------------------------------------------------------------------- + +class ApplicationStatus(models.TextChoices): + DRAFT = "Draft", "Draft" + PENDING_INVENTOR_CONSENT = "Pending Inventor Consent", "Pending Inventor Consent" + SUBMITTED = "Submitted", "Submitted" + UNDER_REVIEW = "Under Review", "Under Review" + REVIEWED = "Reviewed by PCC Admin", "Reviewed by PCC Admin" + FORWARDED = "Forwarded for Director's Review", "Forwarded for Director's Review" + APPROVED = "Approved", "Approved" + NEEDS_REVISION = "Needs Revision", "Needs Revision" + REJECTED = "Rejected", "Rejected" + RESUBMITTED = "Resubmitted", "Resubmitted" + APPEAL = "Appeal", "Appeal" # Feature 1: Lodge Formal Appeal + APPEAL_UNDER_REVIEW = "Appeal Under Review", "Appeal Under Review" + APPEAL_APPROVED = "Appeal Approved", "Appeal Approved" + APPEAL_REJECTED = "Appeal Rejected", "Appeal Rejected" + PATENTABILITY_CHECK_STARTED = "Patentability Check Started", "Patentability Check Started" + PATENTABILITY_CHECK_COMPLETED = "Patentability Check Completed", "Patentability Check Completed" + SEARCH_REPORT_GENERATED = "Search Report Generated", "Search Report Generated" + PATENT_FILED = "Patent Filed", "Patent Filed" + PATENT_PUBLISHED = "Patent Published", "Patent Published" + PATENT_GRANTED = "Patent Granted", "Patent Granted" + PATENT_REFUSED = "Patent Refused", "Patent Refused" + WITHDRAWN = "Withdrawn", "Withdrawn" + EXPIRED = "Expired", "Expired" + + +class DecisionStatus(models.TextChoices): + PENDING = "Pending", "Pending" + APPROVED = "Approved", "Approved" + REJECTED = "Rejected", "Rejected" + NEEDS_REVISION = "Needs Revision", "Needs Revision" + + +class IPType(models.TextChoices): + PATENT = "Patent", "Patent" + COPYRIGHT = "Copyright", "Copyright" + TRADEMARK = "Trademark", "Trademark" + INDUSTRIAL_DESIGN = "Industrial Design", "Industrial Design" + TRADE_SECRET = "Trade Secret", "Trade Secret" + GI = "Geographical Indication", "Geographical Indication" + + +class DevelopmentStage(models.TextChoices): + EMBRYONIC = "Embryonic", "Embryonic" + PARTIALLY_DEVELOPED = "Partially developed", "Partially developed" + OFF_THE_SHELF = "Off-the-shelf", "Off-the-shelf" + + +class CommunicationDirection(models.TextChoices): + INCOMING = "Incoming", "Incoming" + OUTGOING = "Outgoing", "Outgoing" + + +class BudgetDecision(models.TextChoices): + PENDING = "Pending", "Pending" + APPROVED_PCC = "Approved by PCC", "Approved by PCC" + ESCALATED = "Escalated to Director", "Escalated to Director" + APPROVED_DIRECTOR = "Approved by Director", "Approved by Director" + DENIED = "Denied", "Denied" + + +class ConfidentialityLevel(models.TextChoices): + """BR-PMS-019 — Confidentiality markings for legal communications.""" + PUBLIC = "Public", "Public" + INTERNAL = "Internal", "Internal" + CONFIDENTIAL = "Confidential", "Confidential" + PRIVILEGED = "Attorney-Client Privileged", "Attorney-Client Privileged" + + +class PatentabilityRecommendation(models.TextChoices): + """BR-PMS-014 — Attorney assessment recommendation.""" + FILE_PATENT = "File Patent", "File Patent" + DO_NOT_FILE = "Do Not File", "Do Not File" + NEEDS_AMENDMENT = "Needs Amendment", "Needs Amendment" + + +# --------------------------------------------------------------------------- +# File-upload helpers +# --------------------------------------------------------------------------- + +def _unique_path(subfolder, filename): + base, ext = os.path.splitext(filename) + base = base.replace(" ", "_") + ts = now().strftime("%Y%m%d%H%M%S") + return os.path.join(f"patent/{subfolder}", f"{base}_{ts}{ext}") + + +def poc_file_upload_path(instance, filename): + return _unique_path("Section-I/poc_details", filename) + + +def source_agreement_upload_path(instance, filename): + return _unique_path("Section-II/source_agreement_files", filename) + + +def mou_file_upload_path(instance, filename): + return _unique_path("Section-II/mou_files", filename) + + +def form_iii_upload_path(instance, filename): + return _unique_path("Section-III/form_iii_files", filename) + + +def communication_attachment_path(instance, filename): + return _unique_path("communications", filename) + + +def assessment_report_upload_path(instance, filename): + return _unique_path("patentability_assessments", filename) + + +def filing_confirmation_upload_path(instance, filename): + return _unique_path("filing_records", filename) + + +# --------------------------------------------------------------------------- +# Core Models +# --------------------------------------------------------------------------- + +class Applicant(models.Model): + """Faculty / Staff who can submit patent applications.""" + id = models.AutoField(primary_key=True) + user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="patent_applicant") + name = models.CharField(max_length=255) + email = models.EmailField(unique=True) + mobile = models.CharField(max_length=15, blank=True, default="") + address = models.CharField(max_length=255, blank=True, default="") + + def __str__(self): + return self.name + + class Meta: + db_table = "patent_system_applicant" + + +class Application(models.Model): + """ + Central entity for a patent application. + Attorney FK removed — PCC_ADMIN handles all legal interactions externally. + """ + id = models.AutoField(primary_key=True) + title = models.CharField(max_length=255) + primary_applicant = models.ForeignKey( + Applicant, on_delete=models.CASCADE, related_name="applications" + ) + status = models.CharField( + max_length=60, choices=ApplicationStatus.choices, default=ApplicationStatus.DRAFT + ) + decision_status = models.CharField( + max_length=30, choices=DecisionStatus.choices, default=DecisionStatus.PENDING + ) + token_no = models.CharField(max_length=120, blank=True, null=True) + comments = models.TextField(blank=True, default="") + + # Director assignment (BR-PMS-012) + assigned_director = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name="directed_patent_apps" + ) + director_feedback = models.TextField(blank=True, default="") + + # Key dates + submitted_date = models.DateTimeField(blank=True, null=True) + reviewed_by_pcc_date = models.DateTimeField(blank=True, null=True) + forwarded_to_director_date = models.DateTimeField(blank=True, null=True) + director_approval_date = models.DateTimeField(blank=True, null=True) + patentability_check_start_date = models.DateTimeField(blank=True, null=True) + patentability_check_completed_date = models.DateTimeField(blank=True, null=True) + search_report_generated_date = models.DateTimeField(blank=True, null=True) + patent_filed_date = models.DateTimeField(blank=True, null=True) + patent_published_date = models.DateTimeField(blank=True, null=True) + decision_date = models.DateTimeField(blank=True, null=True) + withdrawn_date = models.DateTimeField(blank=True, null=True) + resubmission_deadline = models.DateTimeField(blank=True, null=True) + + last_updated_at = models.DateTimeField(auto_now=True) + created_at = models.DateTimeField(auto_now_add=True) + + # Feature 1: Appeal fields + appeal_date = models.DateTimeField(blank=True, null=True) + appeal_reason = models.TextField(blank=True, default="") + appeal_decision = models.CharField(max_length=60, blank=True, default="") + appeal_decision_date = models.DateTimeField(blank=True, null=True) + appeal_decision_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, + related_name="appeal_decisions" + ) + + def __str__(self): + return f"[{self.id}] {self.title}" + + class Meta: + db_table = "patent_system_application" + ordering = ["-created_at"] + + +# --------------------------------------------------------------------------- +# Application Section Models +# --------------------------------------------------------------------------- + +class ApplicationSectionI(models.Model): + """Technical details of the invention.""" + id = models.AutoField(primary_key=True) + application = models.OneToOneField(Application, on_delete=models.CASCADE, related_name="section_i") + type_of_ip = models.CharField(max_length=50, choices=IPType.choices, default=IPType.PATENT) + area = models.TextField() + problem = models.TextField() + objective = models.TextField() + novelty = models.TextField() + advantages = models.TextField() + is_tested = models.BooleanField(default=False) + poc_details = models.FileField(upload_to=poc_file_upload_path, blank=True, null=True) + applications = models.TextField() + + class Meta: + db_table = "patent_system_application_section_i" + + +class ApplicationSectionII(models.Model): + """Funding & collaboration details.""" + id = models.AutoField(primary_key=True) + application = models.OneToOneField(Application, on_delete=models.CASCADE, related_name="section_ii") + funding_details = models.TextField() + funding_source = models.TextField() + source_agreement = models.FileField(upload_to=source_agreement_upload_path, blank=True, null=True) + publication_details = models.TextField() + mou_details = models.TextField() + mou_file = models.FileField(upload_to=mou_file_upload_path, blank=True, null=True) + research_details = models.TextField() + + class Meta: + db_table = "patent_system_application_section_ii" + + +class ApplicationSectionIII(models.Model): + """Industry / commercialisation details.""" + id = models.AutoField(primary_key=True) + application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="section_iii") + company_name = models.CharField(max_length=255) + contact_person = models.CharField(max_length=255) + contact_no = models.CharField(max_length=15) + development_stage = models.CharField(max_length=30, choices=DevelopmentStage.choices) + form_iii = models.FileField(upload_to=form_iii_upload_path, blank=True, null=True) + + class Meta: + db_table = "patent_system_application_section_iii" + + +# --------------------------------------------------------------------------- +# Inventor association +# --------------------------------------------------------------------------- + +class Inventor(models.Model): + """Many-to-many link between applicants and applications with share %.""" + id = models.AutoField(primary_key=True) + applicant = models.ForeignKey(Applicant, on_delete=models.CASCADE, related_name="inventions") + application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="inventors") + percentage_share = models.DecimalField(max_digits=5, decimal_places=2) + # Feature 2: Inventor consent + has_consent = models.BooleanField(default=False) + consent_date = models.DateTimeField(blank=True, null=True) + + class Meta: + db_table = "patent_system_inventor" + unique_together = ("applicant", "application") + + def __str__(self): + return f"{self.applicant.name} – {self.application.title} ({self.percentage_share}%)" + + +# --------------------------------------------------------------------------- +# Communication Log (replaces Attorney model – BR-PMS-019, UC-007/009) +# --------------------------------------------------------------------------- + +class CommunicationLog(models.Model): + """ + PCC_ADMIN logs all external communications (emails, calls, meetings with + external attorneys / patent offices) here. Stores mail screenshots, + proof pictures, notes, etc. + """ + id = models.AutoField(primary_key=True) + application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="communications") + logged_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="pcc_comm_logs") + direction = models.CharField(max_length=10, choices=CommunicationDirection.choices) + subject = models.CharField(max_length=500) + body = models.TextField(blank=True, default="") + external_party_name = models.CharField(max_length=255, blank=True, default="") + external_party_email = models.EmailField(blank=True, default="") + attachment = models.FileField(upload_to=communication_attachment_path, blank=True, null=True) + confidentiality_level = models.CharField( + max_length=30, choices=ConfidentialityLevel.choices, + default=ConfidentialityLevel.INTERNAL, + help_text="BR-PMS-019: Confidentiality marking for legal communications.", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "patent_system_communication_log" + ordering = ["-created_at"] + + def __str__(self): + return f"[{self.direction}] {self.subject}" + + +# --------------------------------------------------------------------------- +# Budget / Financial Tracking (BR-PMS-008, UC-008, WF-301) +# --------------------------------------------------------------------------- + +class Budget(models.Model): + """Tracks costs and approval for a patent application.""" + id = models.AutoField(primary_key=True) + application = models.OneToOneField(Application, on_delete=models.CASCADE, related_name="budget") + filing_cost = models.DecimalField(max_digits=12, decimal_places=2, default=0) + attorney_fees = models.DecimalField(max_digits=12, decimal_places=2, default=0) + administrative_cost = models.DecimalField(max_digits=12, decimal_places=2, default=0) + total_cost = models.DecimalField(max_digits=12, decimal_places=2, default=0) + decision = models.CharField(max_length=30, choices=BudgetDecision.choices, default=BudgetDecision.PENDING) + decision_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + decision_date = models.DateTimeField(blank=True, null=True) + remarks = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "patent_system_budget" + + def save(self, *args, **kwargs): + self.total_cost = self.filing_cost + self.attorney_fees + self.administrative_cost + super().save(*args, **kwargs) + + def __str__(self): + return f"Budget for {self.application.title}: ₹{self.total_cost}" + + +# --------------------------------------------------------------------------- +# Audit Log (BR-PMS-018) +# --------------------------------------------------------------------------- + +class AuditLog(models.Model): + """Immutable record of every action taken on a patent application.""" + id = models.AutoField(primary_key=True) + application = models.ForeignKey(Application, on_delete=models.CASCADE, related_name="audit_logs") + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) + action = models.CharField(max_length=255) + previous_state = models.CharField(max_length=60, blank=True, default="") + new_state = models.CharField(max_length=60, blank=True, default="") + details = models.TextField(blank=True, default="") + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "patent_system_audit_log" + ordering = ["-timestamp"] + + def __str__(self): + return f"{self.action} on App#{self.application_id} by {self.user}" + + +# --------------------------------------------------------------------------- +# Document (reusable across roles – UC-020) +# --------------------------------------------------------------------------- + +class Document(models.Model): + """Shared / reference documents (guidelines, forms, templates).""" + title = models.CharField(max_length=255) + link = models.URLField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "patent_system_document" + + +# --------------------------------------------------------------------------- +# Attorney Assignment (UC-006, BR-PMS-007) +# PCC_ADMIN records which external attorney is assigned to an application. +# --------------------------------------------------------------------------- + +class AttorneyAssignment(models.Model): + """ + Records the external attorney assigned by PCC_ADMIN to a patent application. + Since attorneys are external to the system, PCC_ADMIN fills in their details + and uploads proof (engagement letter, email confirmation, etc.). + """ + id = models.AutoField(primary_key=True) + application = models.OneToOneField( + Application, on_delete=models.CASCADE, related_name="attorney_assignment" + ) + attorney_name = models.CharField(max_length=255) + attorney_email = models.EmailField(blank=True, default="") + attorney_phone = models.CharField(max_length=20, blank=True, default="") + attorney_firm = models.CharField(max_length=255, blank=True, default="") + specialization = models.CharField( + max_length=255, blank=True, default="", + help_text="Domain expertise of the attorney relevant to this application.", + ) + assigned_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="attorney_assignments_made" + ) + assignment_date = models.DateTimeField(auto_now_add=True) + engagement_proof = models.FileField( + upload_to=communication_attachment_path, blank=True, null=True, + help_text="Upload engagement letter, email screenshot, or other proof of assignment.", + ) + remarks = models.TextField(blank=True, default="") + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "patent_system_attorney_assignment" + + def __str__(self): + return f"Attorney {self.attorney_name} → {self.application.title}" + + +# --------------------------------------------------------------------------- +# Patentability Assessment (UC-007, BR-PMS-014) +# PCC_ADMIN records the external attorney's patentability opinion. +# --------------------------------------------------------------------------- + +class PatentabilityAssessment(models.Model): + """ + Stores the external attorney's patentability opinion, prior-art search + results, scores, and recommendation. All data is entered by PCC_ADMIN + based on the attorney's report. + """ + id = models.AutoField(primary_key=True) + application = models.OneToOneField( + Application, on_delete=models.CASCADE, related_name="patentability_assessment" + ) + assessed_by_attorney = models.CharField( + max_length=255, blank=True, default="", + help_text="Name of the external attorney who performed the assessment.", + ) + novelty_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0, + help_text="Novelty score (0-100) as per attorney's evaluation.", + ) + non_obviousness_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0, + help_text="Non-obviousness score (0-100).", + ) + utility_score = models.DecimalField( + max_digits=5, decimal_places=2, default=0, + help_text="Utility / industrial applicability score (0-100).", + ) + search_completeness = models.DecimalField( + max_digits=5, decimal_places=2, default=0, + help_text="Prior-art search completeness percentage (0-100).", + ) + recommendation = models.CharField( + max_length=30, choices=PatentabilityRecommendation.choices, + default=PatentabilityRecommendation.FILE_PATENT, + ) + opinion_summary = models.TextField( + blank=True, default="", + help_text="Summary of the attorney's patentability opinion.", + ) + prior_art_references = models.TextField( + blank=True, default="", + help_text="Prior art references identified during the search.", + ) + attorney_report = models.FileField( + upload_to=assessment_report_upload_path, blank=True, null=True, + help_text="Full attorney report / opinion document.", + ) + recorded_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="recorded_assessments" + ) + assessment_date = models.DateTimeField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "patent_system_patentability_assessment" + + def __str__(self): + return f"Assessment for {self.application.title}: {self.recommendation}" + + +# --------------------------------------------------------------------------- +# Filing Record (UC-009, BR-PMS-017, WF-601) +# PCC_ADMIN logs external filing details with patent office. +# --------------------------------------------------------------------------- + +class FilingRecord(models.Model): + """ + Captures details when PCC_ADMIN logs a patent filing with a national or + international patent office. Stores external filing ID, jurisdiction, + confirmation proof, etc. + """ + id = models.AutoField(primary_key=True) + application = models.OneToOneField( + Application, on_delete=models.CASCADE, related_name="filing_record" + ) + filing_office = models.CharField( + max_length=255, default="Indian Patent Office", + help_text="Name of the patent office where filed.", + ) + jurisdiction = models.CharField( + max_length=100, blank=True, default="India", + help_text="Filing jurisdiction (e.g. India, US, PCT).", + ) + external_filing_id = models.CharField( + max_length=255, blank=True, default="", + help_text="Official filing/application number from the patent office.", + ) + filing_date = models.DateTimeField( + blank=True, null=True, + help_text="Official date of filing as recorded by the patent office.", + ) + confirmation_proof = models.FileField( + upload_to=filing_confirmation_upload_path, blank=True, null=True, + help_text="Upload filing receipt, confirmation email screenshot, etc.", + ) + international_filing_justification = models.TextField( + blank=True, default="", + help_text="BR-PMS-017: Required justification if filing outside Indian Patent Office.", + ) + filed_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="filed_patents" + ) + remarks = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "patent_system_filing_record" + + def __str__(self): + return f"Filing {self.external_filing_id} for {self.application.title}" + + +# --------------------------------------------------------------------------- +# Feature 4: Patent Notification (Deadline & Alert System) +# --------------------------------------------------------------------------- + +class NotificationType(models.TextChoices): + DEADLINE_APPROACHING = "Deadline Approaching", "Deadline Approaching" + DEADLINE_EXPIRED = "Deadline Expired", "Deadline Expired" + STATUS_CHANGE = "Status Change", "Status Change" + ACTION_REQUIRED = "Action Required", "Action Required" + APPEAL_UPDATE = "Appeal Update", "Appeal Update" + CONSENT_REQUIRED = "Consent Required", "Consent Required" + REVISION_REQUESTED = "Revision Requested", "Revision Requested" + APPLICATION_APPROVED = "Application Approved", "Application Approved" + APPLICATION_REJECTED = "Application Rejected", "Application Rejected" + + +class PatentNotification(models.Model): + """ + Feature 4: Deadline & Alert System + Stores notifications for patent-related events and deadlines. + """ + id = models.AutoField(primary_key=True) + recipient = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="patent_notifications" + ) + application = models.ForeignKey( + Application, on_delete=models.CASCADE, related_name="notifications", + null=True, blank=True + ) + notification_type = models.CharField( + max_length=50, choices=NotificationType.choices + ) + title = models.CharField(max_length=255) + message = models.TextField() + is_read = models.BooleanField(default=False) + deadline_date = models.DateTimeField(blank=True, null=True) + action_url = models.CharField(max_length=500, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "patent_system_notification" + ordering = ["-created_at"] + + def __str__(self): + return f"[{self.notification_type}] {self.title}" + + +# --------------------------------------------------------------------------- +# Feature 5: Document Version Control +# --------------------------------------------------------------------------- + +def versioned_document_upload_path(instance, filename): + return _unique_path(f"documents/v{instance.version}", filename) + + +class ApplicationDocument(models.Model): + """ + Feature 5: Document Version Control + Tracks document versions for patent applications. + """ + id = models.AutoField(primary_key=True) + application = models.ForeignKey( + Application, on_delete=models.CASCADE, related_name="documents" + ) + document_type = models.CharField( + max_length=100, + help_text="Type of document (e.g., POC, MOU, Form III, Supporting Doc)" + ) + title = models.CharField(max_length=255) + file = models.FileField(upload_to=versioned_document_upload_path) + version = models.PositiveIntegerField(default=1) + description = models.TextField(blank=True, default="") + uploaded_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="uploaded_documents" + ) + is_current = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "patent_system_application_document" + ordering = ["-version", "-created_at"] + unique_together = ("application", "document_type", "version") + + def __str__(self): + return f"{self.title} v{self.version}" + + def save(self, *args, **kwargs): + if not self.pk: + # Auto-increment version for new documents of same type + existing = ApplicationDocument.objects.filter( + application=self.application, + document_type=self.document_type + ).order_by("-version").first() + if existing: + self.version = existing.version + 1 + # Mark old versions as not current + ApplicationDocument.objects.filter( + application=self.application, + document_type=self.document_type + ).update(is_current=False) + super().save(*args, **kwargs) diff --git a/FusionIIIT/applications/patent_system/selectors.py b/FusionIIIT/applications/patent_system/selectors.py new file mode 100644 index 000000000..381d7b04e --- /dev/null +++ b/FusionIIIT/applications/patent_system/selectors.py @@ -0,0 +1,679 @@ +""" +Patent Management System — Selectors (Database Queries) + +All .objects.* usage is centralised here so views/services stay thin. + +──────────────────────────────────────────────────────────────────── +MIGRATION SCRIPT — run these commands after deploying this code: +──────────────────────────────────────────────────────────────────── + + cd FusionIIIT + python manage.py makemigrations patent_system + python manage.py migrate patent_system + +If you have existing data in the old Attorney / AssociatedWith tables +and want to preserve it, run the following data-migration SQL *after* +the Django migration completes: + + -- 1. Copy AssociatedWith → Inventor + INSERT INTO patent_system_inventor (applicant_id, application_id, percentage_share) + SELECT applicant_id, application_id, percentage_share + FROM patent_system_associatedwith + ON CONFLICT DO NOTHING; + + -- 2. (Optional) Drop old tables once verified + -- DROP TABLE IF EXISTS patent_system_attorney; + -- DROP TABLE IF EXISTS patent_system_associatedwith; + +──────────────────────────────────────────────────────────────────── +""" + +from django.db.models import Q, Count +from django.shortcuts import get_object_or_404 + +from .models import ( + Application, ApplicationStatus, DecisionStatus, + ApplicationSectionI, ApplicationSectionII, ApplicationSectionIII, + Applicant, Inventor, CommunicationLog, Budget, AuditLog, Document, + AttorneyAssignment, PatentabilityAssessment, FilingRecord, + PatentNotification, ApplicationDocument, +) +from applications.globals.models import ExtraInfo, HoldsDesignation + + +# --------------------------------------------------------------------------- +# Generic helpers +# --------------------------------------------------------------------------- + +def get_application_or_404(application_id): + return get_object_or_404(Application, id=application_id) + + +def _enrich_applicant_info(user): + """Return dict with department + designation for a user.""" + extra = ExtraInfo.objects.filter(user=user).select_related("department").first() + dept = extra.department.name if extra and extra.department else "Unknown" + hd = HoldsDesignation.objects.filter(user=user).select_related("designation").first() + desig = hd.designation.name if hd else "Unknown" + return {"department": dept, "designation": desig} + + +def _section_i_data(app): + s = ApplicationSectionI.objects.filter(application=app).first() + if not s: + return {} + return { + "type_of_ip": s.type_of_ip, + "area": s.area, + "problem": s.problem, + "objective": s.objective, + "novelty": s.novelty, + "advantages": s.advantages, + "is_tested": s.is_tested, + "poc_details": s.poc_details.url if s.poc_details else None, + "applications": s.applications, + } + + +def _section_ii_data(app): + s = ApplicationSectionII.objects.filter(application=app).first() + if not s: + return {} + return { + "funding_details": s.funding_details, + "funding_source": s.funding_source, + "source_agreement": s.source_agreement.url if s.source_agreement else None, + "publication_details": s.publication_details, + "mou_details": s.mou_details, + "mou_file": s.mou_file.url if s.mou_file else None, + "research_details": s.research_details, + } + + +def _section_iii_data(app): + items = ApplicationSectionIII.objects.filter(application=app) + return [ + { + "company_name": s.company_name, + "contact_person": s.contact_person, + "contact_no": s.contact_no, + "development_stage": s.development_stage, + "form_iii": s.form_iii.url if s.form_iii else None, + } + for s in items + ] + + +def _inventors_data(app): + return [ + { + "name": inv.applicant.name, + "email": inv.applicant.email, + "mobile": inv.applicant.mobile, + "address": inv.applicant.address, + "percentage_share": str(inv.percentage_share), + "has_consent": inv.has_consent, + "consent_date": inv.consent_date.isoformat() if inv.consent_date else None, + } + for inv in Inventor.objects.filter(application=app).select_related("applicant") + ] + + +def _dates_dict(app): + return { + "submitted_date": app.submitted_date, + "reviewed_by_pcc_date": app.reviewed_by_pcc_date, + "forwarded_to_director_date": app.forwarded_to_director_date, + "director_approval_date": app.director_approval_date, + "patentability_check_start_date": app.patentability_check_start_date, + "patentability_check_completed_date": app.patentability_check_completed_date, + "search_report_generated_date": app.search_report_generated_date, + "patent_filed_date": app.patent_filed_date, + "patent_published_date": app.patent_published_date, + "decision_date": app.decision_date, + "withdrawn_date": app.withdrawn_date, + "resubmission_deadline": app.resubmission_deadline, + } + + +def _attorney_assignment_data(app): + """Return attorney assignment details for an application (UC-006).""" + try: + a = AttorneyAssignment.objects.get(application=app) + return { + "id": a.id, + "attorney_name": a.attorney_name, + "attorney_email": a.attorney_email, + "attorney_phone": a.attorney_phone, + "attorney_firm": a.attorney_firm, + "specialization": a.specialization, + "assigned_by": a.assigned_by.get_full_name() if a.assigned_by else None, + "assignment_date": a.assignment_date, + "engagement_proof": a.engagement_proof.url if a.engagement_proof else None, + "remarks": a.remarks, + "is_active": a.is_active, + } + except AttorneyAssignment.DoesNotExist: + return None + + +def _patentability_assessment_data(app): + """Return patentability assessment details for an application (UC-007, BR-PMS-014).""" + try: + p = PatentabilityAssessment.objects.get(application=app) + return { + "id": p.id, + "assessed_by_attorney": p.assessed_by_attorney, + "novelty_score": str(p.novelty_score), + "non_obviousness_score": str(p.non_obviousness_score), + "utility_score": str(p.utility_score), + "search_completeness": str(p.search_completeness), + "recommendation": p.recommendation, + "opinion_summary": p.opinion_summary, + "prior_art_references": p.prior_art_references, + "attorney_report": p.attorney_report.url if p.attorney_report else None, + "recorded_by": p.recorded_by.get_full_name() if p.recorded_by else None, + "assessment_date": p.assessment_date, + "created_at": p.created_at, + } + except PatentabilityAssessment.DoesNotExist: + return None + + +def _filing_record_data(app): + """Return filing record details for an application (UC-009, WF-601).""" + try: + f = FilingRecord.objects.get(application=app) + return { + "id": f.id, + "filing_office": f.filing_office, + "jurisdiction": f.jurisdiction, + "external_filing_id": f.external_filing_id, + "filing_date": f.filing_date, + "confirmation_proof": f.confirmation_proof.url if f.confirmation_proof else None, + "international_filing_justification": f.international_filing_justification, + "filed_by": f.filed_by.get_full_name() if f.filed_by else None, + "remarks": f.remarks, + "created_at": f.created_at, + } + except FilingRecord.DoesNotExist: + return None + + +def full_application_detail(app): + """Return complete dict for a single application (used by all detail views).""" + primary = app.primary_applicant + info = _enrich_applicant_info(primary.user) if primary else {} + return { + "application_id": app.id, + "title": app.title, + "status": app.status, + "decision_status": app.decision_status, + "token_no": app.token_no or "Token not generated", + "comments": app.comments, + "director_feedback": app.director_feedback, + "primary_applicant_name": primary.name if primary else None, + "primary_applicant_department": info.get("department"), + "primary_applicant_designation": info.get("designation"), + "dates": _dates_dict(app), + "inventors": _inventors_data(app), + "section_I": _section_i_data(app), + "section_II": _section_ii_data(app), + "section_III": _section_iii_data(app), + "attorney_assignment": _attorney_assignment_data(app), + "patentability_assessment": _patentability_assessment_data(app), + "filing_record": _filing_record_data(app), + "last_updated_at": app.last_updated_at, + } + + +# --------------------------------------------------------------------------- +# Applicant selectors +# --------------------------------------------------------------------------- + +def get_applicant_applications(user): + """All applications where user is an inventor (UC-002 old / UC-005 new).""" + try: + applicant = Applicant.objects.get(user=user) + except Applicant.DoesNotExist: + return [] + + app_ids = Inventor.objects.filter(applicant=applicant).values_list("application_id", flat=True) + apps = Application.objects.filter(id__in=app_ids).select_related("primary_applicant") + + return [ + { + "application_id": a.id, + "title": a.title, + "status": a.status, + "token_no": a.token_no, + "submitted_date": a.submitted_date, + } + for a in apps + ] + + +def get_applicant_application_detail(user, application_id): + """Single application detail for an applicant (UC-003).""" + app = get_application_or_404(application_id) + # verify association + try: + applicant = Applicant.objects.get(user=user) + except Applicant.DoesNotExist: + return None + if not Inventor.objects.filter(applicant=applicant, application=app).exists(): + return None + return full_application_detail(app) + + +def get_pending_consent_applications(user): + """Get applications where current user is an inventor and consent is pending.""" + try: + applicant = Applicant.objects.get(user=user) + except Applicant.DoesNotExist: + return [] + + # Find applications where user is an inventor but hasn't given consent yet + pending_inventions = Inventor.objects.filter( + applicant=applicant, + has_consent=False + ).select_related("application", "application__primary_applicant") + + result = [] + for invention in pending_inventions: + app = invention.application + + # Get total inventors and consents received + all_inventors = Inventor.objects.filter(application=app) + total_inventors = all_inventors.count() + consents_received = all_inventors.filter(has_consent=True).count() + + result.append({ + "application_id": app.id, + "title": app.title, + "token_number": app.token_no, + "status": app.status, + "primary_applicant": app.primary_applicant.name if app.primary_applicant else "Unknown", + "submitted_date": app.submitted_date.isoformat() if app.submitted_date else None, + "your_percentage": invention.percentage_share, + "total_inventors": total_inventors, + "consents_received": consents_received, + }) + + return result + + +# --------------------------------------------------------------------------- +# PCC Admin selectors +# --------------------------------------------------------------------------- + +def get_new_applications_pcc(): + """Applications with Submitted / Reviewed / Resubmitted status (UC-005 old).""" + statuses = [ApplicationStatus.SUBMITTED, ApplicationStatus.REVIEWED, ApplicationStatus.RESUBMITTED] + apps = Application.objects.filter(status__in=statuses).select_related("primary_applicant") + result = {} + for a in apps: + info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} + result[a.id] = { + "title": a.title, + "submitted_by": a.primary_applicant.name if a.primary_applicant else "Unknown", + "designation": info.get("designation", "Unknown"), + "department": info.get("department", "Unknown"), + "submitted_on": a.submitted_date.strftime("%Y-%m-%d") if a.submitted_date else "Unknown", + "status": a.status, + } + return result + + +def get_ongoing_applications_pcc(): + """Applications in active processing (post-director) for PCC Admin.""" + statuses = [ + ApplicationStatus.FORWARDED, + ApplicationStatus.APPROVED, + ApplicationStatus.PATENTABILITY_CHECK_STARTED, + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED, + ApplicationStatus.SEARCH_REPORT_GENERATED, + ApplicationStatus.PATENT_FILED, + ApplicationStatus.PATENT_PUBLISHED, + ] + apps = Application.objects.filter(status__in=statuses).select_related("primary_applicant") + result = {} + for a in apps: + info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} + result[a.id] = { + "token_no": a.token_no or "Token not generated", + "title": a.title, + "submitted_by": a.primary_applicant.name if a.primary_applicant else "Unknown", + "designation": info.get("designation", "Unknown"), + "department": info.get("department", "Unknown"), + "submitted_on": a.submitted_date.strftime("%Y-%m-%d") if a.submitted_date else "Unknown", + "status": a.status, + } + return result + + +def get_past_applications_pcc(): + """Decided applications for PCC Admin.""" + apps = Application.objects.filter( + decision_status__in=[DecisionStatus.APPROVED, DecisionStatus.REJECTED] + ).select_related("primary_applicant") + result = {} + for a in apps: + info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} + result[a.id] = { + "token_no": a.token_no or "Token not generated", + "title": a.title, + "submitted_by": a.primary_applicant.name if a.primary_applicant else "Unknown", + "designation": info.get("designation", "Unknown"), + "department": info.get("department", "Unknown"), + "submitted_on": a.submitted_date.strftime("%Y-%m-%d") if a.submitted_date else "Unknown", + "decision_status": a.decision_status, + } + return result + + +def get_pcc_application_detail(application_id): + app = get_application_or_404(application_id) + detail = full_application_detail(app) + # Attach communication logs (with confidentiality level — BR-PMS-019) + logs = CommunicationLog.objects.filter(application=app) + detail["communications"] = [ + { + "id": l.id, + "direction": l.direction, + "subject": l.subject, + "body": l.body, + "external_party_name": l.external_party_name, + "external_party_email": l.external_party_email, + "attachment": l.attachment.url if l.attachment else None, + "confidentiality_level": l.confidentiality_level, + "created_at": l.created_at, + } + for l in logs + ] + # Attach budget + try: + b = Budget.objects.get(application=app) + detail["budget"] = { + "filing_cost": str(b.filing_cost), + "attorney_fees": str(b.attorney_fees), + "administrative_cost": str(b.administrative_cost), + "total_cost": str(b.total_cost), + "decision": b.decision, + "remarks": b.remarks, + } + except Budget.DoesNotExist: + detail["budget"] = None + return detail + + +# --------------------------------------------------------------------------- +# Director selectors +# --------------------------------------------------------------------------- + +def get_director_new_applications(): + """Applications forwarded for Director's review.""" + apps = Application.objects.filter( + status=ApplicationStatus.FORWARDED + ).select_related("primary_applicant") + result = {} + for a in apps: + info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} + result[a.id] = { + "token_no": a.token_no or "Token not generated", + "title": a.title, + "submitted_by": a.primary_applicant.name if a.primary_applicant else "Unknown", + "department": info.get("department", "Unknown"), + "forwarded_on": a.forwarded_to_director_date.strftime("%Y-%m-%d %H:%M") if a.forwarded_to_director_date else "Unknown", + } + return result + + +def get_director_reviewed_applications(): + """Applications the director has already acted on.""" + statuses = [ + ApplicationStatus.APPROVED, + ApplicationStatus.REJECTED, + ApplicationStatus.NEEDS_REVISION, + ApplicationStatus.PATENTABILITY_CHECK_STARTED, + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED, + ApplicationStatus.SEARCH_REPORT_GENERATED, + ApplicationStatus.PATENT_FILED, + ApplicationStatus.PATENT_PUBLISHED, + ApplicationStatus.PATENT_GRANTED, + ApplicationStatus.PATENT_REFUSED, + ] + apps = Application.objects.filter(status__in=statuses).select_related("primary_applicant") + result = {} + for a in apps: + info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} + result[a.id] = { + "token_no": a.token_no or "Token not generated", + "title": a.title, + "submitted_by": a.primary_applicant.name if a.primary_applicant else "Unknown", + "department": info.get("department", "Unknown"), + "forwarded_on": a.forwarded_to_director_date if a.forwarded_to_director_date else None, + "decision_date": a.decision_date if a.decision_date else None, + "status": a.status, + } + return result + + +def get_director_application_detail(application_id): + app = get_application_or_404(application_id) + return full_application_detail(app) + + +# --------------------------------------------------------------------------- +# Analytics / Reports (UC-015) +# --------------------------------------------------------------------------- + +def get_application_stats(year=None): + """Aggregate counts per status for analytics dashboards. + Optionally filter by year of submission. + """ + qs = Application.objects.all() + if year: + try: + qs = qs.filter(submitted_date__year=int(year)) + except (ValueError, TypeError): + pass + return ( + qs.values("status") + .annotate(count=Count("id")) + .order_by("status") + ) + + +def get_available_years(): + """Return sorted list of years that have at least one application.""" + from django.db.models.functions import ExtractYear + years = ( + Application.objects + .annotate(year=ExtractYear("submitted_date")) + .values_list("year", flat=True) + .distinct() + .order_by("-year") + ) + return [y for y in years if y is not None] + + +# --------------------------------------------------------------------------- +# Communication logs +# --------------------------------------------------------------------------- + +def get_communication_logs(application_id): + return CommunicationLog.objects.filter(application_id=application_id) + + +# --------------------------------------------------------------------------- +# Budget +# --------------------------------------------------------------------------- + +def get_budget(application_id): + try: + return Budget.objects.get(application_id=application_id) + except Budget.DoesNotExist: + return None + + +# --------------------------------------------------------------------------- +# Audit logs +# --------------------------------------------------------------------------- + +def get_audit_logs(application_id): + return AuditLog.objects.filter(application_id=application_id) + + +# --------------------------------------------------------------------------- +# Documents +# --------------------------------------------------------------------------- + +def get_all_documents(): + return Document.objects.all() + + +def get_document_or_404(doc_id): + return get_object_or_404(Document, id=doc_id) + + +# --------------------------------------------------------------------------- +# Attorney Assignment (UC-006) +# --------------------------------------------------------------------------- + +def get_attorney_assignment(application_id): + try: + return AttorneyAssignment.objects.get(application_id=application_id) + except AttorneyAssignment.DoesNotExist: + return None + + +# --------------------------------------------------------------------------- +# Patentability Assessment (UC-007) +# --------------------------------------------------------------------------- + +def get_patentability_assessment(application_id): + try: + return PatentabilityAssessment.objects.get(application_id=application_id) + except PatentabilityAssessment.DoesNotExist: + return None + + +# --------------------------------------------------------------------------- +# Filing Record (UC-009) +# --------------------------------------------------------------------------- + +def get_filing_record(application_id): + try: + return FilingRecord.objects.get(application_id=application_id) + except FilingRecord.DoesNotExist: + return None + + +# --------------------------------------------------------------------------- +# Feature 2: Inventor Consent Status +# --------------------------------------------------------------------------- + +def get_inventors_consent_status(application_id): + """Get consent status for all inventors on an application.""" + inventors = Inventor.objects.filter(application_id=application_id).select_related("applicant") + total_share = sum(inv.percentage_share for inv in inventors) + all_consented = all(inv.has_consent for inv in inventors) if inventors else False + + return { + "application_id": application_id, + "total_share": str(total_share), + "shares_valid": total_share == 100, + "all_consented": all_consented, + "inventors": [ + { + "id": inv.id, + "applicant_id": inv.applicant.id, + "name": inv.applicant.name, + "email": inv.applicant.email, + "percentage_share": str(inv.percentage_share), + "has_consent": inv.has_consent, + "consent_date": inv.consent_date.isoformat() if inv.consent_date else None, + } + for inv in inventors + ] + } + + +# --------------------------------------------------------------------------- +# Feature 4: Notifications +# --------------------------------------------------------------------------- + +def get_user_notifications(user, unread_only=False, limit=50): + """Get notifications for a user.""" + qs = PatentNotification.objects.filter(recipient=user) + if unread_only: + qs = qs.filter(is_read=False) + return qs[:limit] + + +def get_unread_notification_count(user): + """Get count of unread notifications.""" + return PatentNotification.objects.filter(recipient=user, is_read=False).count() + + +# --------------------------------------------------------------------------- +# Feature 5: Application Documents +# --------------------------------------------------------------------------- + +def get_application_documents(application_id, document_type=None, current_only=False): + """Get documents for an application with optional filtering.""" + qs = ApplicationDocument.objects.filter(application_id=application_id) + if document_type: + qs = qs.filter(document_type=document_type) + if current_only: + qs = qs.filter(is_current=True) + return qs + + +def get_document_history(application_id, document_type): + """Get version history for a specific document type.""" + return ApplicationDocument.objects.filter( + application_id=application_id, + document_type=document_type + ).order_by("-version") + + +# --------------------------------------------------------------------------- +# Feature 6: Applications pending appeal review +# --------------------------------------------------------------------------- + +def get_applications_pending_appeal(): + """Get applications with pending appeals for PCC Admin.""" + apps = Application.objects.filter( + status=ApplicationStatus.APPEAL + ).select_related("primary_applicant") + result = {} + for a in apps: + info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} + result[a.id] = { + "title": a.title, + "submitted_by": a.primary_applicant.name if a.primary_applicant else "Unknown", + "department": info.get("department", "Unknown"), + "appeal_date": a.appeal_date, + "appeal_reason": a.appeal_reason[:100] + "..." if len(a.appeal_reason) > 100 else a.appeal_reason, + } + return result + + +def get_appeals_under_review(): + """Get appeals under Director review.""" + apps = Application.objects.filter( + status=ApplicationStatus.APPEAL_UNDER_REVIEW + ).select_related("primary_applicant") + result = {} + for a in apps: + info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} + result[a.id] = { + "title": a.title, + "submitted_by": a.primary_applicant.name if a.primary_applicant else "Unknown", + "department": info.get("department", "Unknown"), + "appeal_date": a.appeal_date, + "appeal_reason": a.appeal_reason, + } + return result diff --git a/FusionIIIT/applications/patent_system/services.py b/FusionIIIT/applications/patent_system/services.py new file mode 100644 index 000000000..9eda9583e --- /dev/null +++ b/FusionIIIT/applications/patent_system/services.py @@ -0,0 +1,1554 @@ +""" +Patent Management System — Services (Business Logic) +All business rules, validations, status transitions, token generation. +""" + +import logging +from datetime import timedelta +from django.utils.timezone import now +from django.contrib.auth.models import User +from django.db import transaction, models + +from .models import ( + Application, ApplicationStatus, DecisionStatus, + Applicant, Inventor, AuditLog, Budget, BudgetDecision, + ApplicationSectionI, ApplicationSectionII, ApplicationSectionIII, + CommunicationLog, AttorneyAssignment, PatentabilityAssessment, + FilingRecord, PatentabilityRecommendation, + PatentNotification, NotificationType, ApplicationDocument, +) + +from applications.globals.models import ExtraInfo, HoldsDesignation + +logger = logging.getLogger(__name__) + +BUDGET_THRESHOLD = 100000 # ₹1,00,000 — escalation threshold (BR-PMS-008) +REVISION_WINDOW_DAYS = 60 # BR-PMS-016 + + +# --------------------------------------------------------------------------- +# Custom Exceptions +# --------------------------------------------------------------------------- + +class PatentServiceError(Exception): + """Base exception for patent service errors.""" + def __init__(self, message, code=400): + self.message = message + self.code = code + super().__init__(self.message) + + +class UnauthorizedError(PatentServiceError): + def __init__(self, message="You are not authorized to perform this action."): + super().__init__(message, code=403) + + +class NotFoundError(PatentServiceError): + def __init__(self, message="Resource not found."): + super().__init__(message, code=404) + + +class ValidationError(PatentServiceError): + def __init__(self, message="Validation failed."): + super().__init__(message, code=400) + + +class ConflictOfInterestError(PatentServiceError): + def __init__(self, message="Conflict of interest detected."): + super().__init__(message, code=409) + + +# --------------------------------------------------------------------------- +# Role Helpers (BR-PMS-002) +# --------------------------------------------------------------------------- + +def _get_user_designation(user): + """Return the designation name for a user, or None.""" + hd = HoldsDesignation.objects.filter(user=user).select_related("designation").first() + return hd.designation.name if hd else None + + +def _get_user_extra_info(user): + return ExtraInfo.objects.filter(user=user).first() + + +def is_pcc_admin(user): + designation = _get_user_designation(user) + return designation and "PCC" in designation.upper() + + +def is_director(user): + designation = _get_user_designation(user) + return designation and "director" in designation.lower() + + +def assert_pcc_admin(user): + if not is_pcc_admin(user): + raise UnauthorizedError("Only PCC Admin can perform this action.") + + +def assert_director(user): + if not is_director(user): + raise UnauthorizedError("Only Director can perform this action.") + + +def assert_applicant(user, application): + """User must be associated with the application as inventor.""" + try: + applicant = Applicant.objects.get(user=user) + except Applicant.DoesNotExist: + raise UnauthorizedError("User is not a registered applicant.") + if not Inventor.objects.filter(applicant=applicant, application=application).exists(): + raise UnauthorizedError("You are not associated with this application.") + return applicant + + +# --------------------------------------------------------------------------- +# Audit helper (BR-PMS-018) +# --------------------------------------------------------------------------- + +def _audit(application, user, action, prev="", new="", details=""): + AuditLog.objects.create( + application=application, + user=user, + action=action, + previous_state=prev, + new_state=new, + details=details, + ) + + +# --------------------------------------------------------------------------- +# VALID STATUS TRANSITIONS (BR-PMS-004, BR-PMS-009) +# --------------------------------------------------------------------------- + +VALID_TRANSITIONS = { + ApplicationStatus.DRAFT: [ApplicationStatus.PENDING_INVENTOR_CONSENT], + ApplicationStatus.PENDING_INVENTOR_CONSENT: [ApplicationStatus.SUBMITTED, ApplicationStatus.NEEDS_REVISION, ApplicationStatus.WITHDRAWN], + ApplicationStatus.SUBMITTED: [ApplicationStatus.UNDER_REVIEW, ApplicationStatus.REVIEWED, ApplicationStatus.WITHDRAWN], + ApplicationStatus.UNDER_REVIEW: [ApplicationStatus.REVIEWED, ApplicationStatus.WITHDRAWN], + ApplicationStatus.REVIEWED: [ApplicationStatus.FORWARDED, ApplicationStatus.DRAFT, ApplicationStatus.WITHDRAWN], + ApplicationStatus.FORWARDED: [ApplicationStatus.APPROVED, ApplicationStatus.REJECTED, ApplicationStatus.NEEDS_REVISION], + ApplicationStatus.APPROVED: [ + ApplicationStatus.PATENTABILITY_CHECK_STARTED, + ApplicationStatus.WITHDRAWN, + ], + ApplicationStatus.NEEDS_REVISION: [ApplicationStatus.RESUBMITTED, ApplicationStatus.EXPIRED, ApplicationStatus.WITHDRAWN], + ApplicationStatus.REJECTED: [ApplicationStatus.APPEAL, ApplicationStatus.RESUBMITTED, ApplicationStatus.EXPIRED, ApplicationStatus.WITHDRAWN], + ApplicationStatus.RESUBMITTED: [ApplicationStatus.UNDER_REVIEW], + # Feature 1: Appeal workflow transitions + ApplicationStatus.APPEAL: [ApplicationStatus.APPEAL_UNDER_REVIEW], + ApplicationStatus.APPEAL_UNDER_REVIEW: [ApplicationStatus.APPEAL_APPROVED, ApplicationStatus.APPEAL_REJECTED], + ApplicationStatus.APPEAL_APPROVED: [ApplicationStatus.FORWARDED], # Goes back to director review + ApplicationStatus.APPEAL_REJECTED: [ApplicationStatus.EXPIRED, ApplicationStatus.WITHDRAWN], + ApplicationStatus.PATENTABILITY_CHECK_STARTED: [ApplicationStatus.PATENTABILITY_CHECK_COMPLETED], + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED: [ApplicationStatus.SEARCH_REPORT_GENERATED], + ApplicationStatus.SEARCH_REPORT_GENERATED: [ApplicationStatus.PATENT_FILED], + ApplicationStatus.PATENT_FILED: [ApplicationStatus.PATENT_PUBLISHED], + ApplicationStatus.PATENT_PUBLISHED: [ApplicationStatus.PATENT_GRANTED, ApplicationStatus.PATENT_REFUSED], +} + + +def _validate_transition(current, target): + allowed = VALID_TRANSITIONS.get(current, []) + if target not in allowed: + raise ValidationError( + f"Invalid status transition from '{current}' to '{target}'. " + f"Allowed: {[s.value for s in allowed]}" + ) + + +# --------------------------------------------------------------------------- +# Token generation (BR-PMS-010) +# --------------------------------------------------------------------------- + +def _generate_token(application): + applicant = application.primary_applicant + user = applicant.user + extra = _get_user_extra_info(user) + dept = extra.department.name[:3].upper() if extra and extra.department else "UNK" + date_str = application.submitted_date.strftime("%Y%m%d") if application.submitted_date else now().strftime("%Y%m%d") + app_id = f"{application.id:06d}" + last = Application.objects.filter(token_no__isnull=False).order_by("-id").first() + serial = int(last.token_no.split("/")[-1]) + 1 if last and last.token_no else 104 + return f"IIITDMJ/{dept}/{date_str}/{app_id}/{serial:03d}" + + +# --------------------------------------------------------------------------- +# Conflict-of-interest check (BR-PMS-003) +# --------------------------------------------------------------------------- + +def _check_director_conflict(director_user, application): + """Director must NOT be listed as an inventor on the application.""" + try: + applicant = Applicant.objects.get(user=director_user) + if Inventor.objects.filter(applicant=applicant, application=application).exists(): + raise ConflictOfInterestError( + "Director is listed as an inventor on this application and cannot review it." + ) + except Applicant.DoesNotExist: + pass # Director is not an applicant — no conflict + + +# =========================================================================== +# SERVICE FUNCTIONS (one per use-case / action) +# =========================================================================== + +# ── UC-001: Submit Application ──────────────────────────────────────────── + +@transaction.atomic +def submit_application(user, data, files): + """ + Create and submit a new patent application (WF-101). + Returns the created Application instance. + """ + required = [ + "title", "inventors", "area_of_invention", "problem_statement", + "objective", "ip_type", "novelty", "advantages", + "tested_experimentally", "applications", + "funding_details", "funding_source", "publication_details", + "mou_details", "research_details", "company_details", + "development_stage", + ] + for f in required: + if f not in data: + raise ValidationError(f"Missing required field: {f}") + + inventors_data = data["inventors"] + if not isinstance(inventors_data, list) or len(inventors_data) == 0: + raise ValidationError("At least one inventor is required.") + + # Applicant profile + applicant, _ = Applicant.objects.get_or_create( + user=user, + defaults={ + "email": user.email, + "name": user.get_full_name() or user.username, + }, + ) + + application = Application.objects.create( + title=data["title"], + status=ApplicationStatus.PENDING_INVENTOR_CONSENT, + decision_status=DecisionStatus.PENDING, + submitted_date=now(), + primary_applicant=applicant, + ) + + # Section I + ApplicationSectionI.objects.create( + application=application, + type_of_ip=data["ip_type"], + area=data["area_of_invention"], + problem=data["problem_statement"], + objective=data["objective"], + novelty=data["novelty"], + advantages=data["advantages"], + is_tested=data["tested_experimentally"], + applications=data["applications"], + poc_details=files.get("poc_details"), + ) + + # Section II + ApplicationSectionII.objects.create( + application=application, + funding_details=data["funding_details"], + funding_source=data["funding_source"], + source_agreement=files.get("source_file"), + publication_details=data["publication_details"], + mou_details=data["mou_details"], + mou_file=files.get("mou_file"), + research_details=data["research_details"], + ) + + # Section III (multiple companies) + companies = data.get("company_details", []) + if not isinstance(companies, list): + raise ValidationError("company_details must be a list.") + for c in companies: + if not all(k in c for k in ("company_name", "contact_person", "contact_no")): + raise ValidationError("Each company must have company_name, contact_person, contact_no.") + ApplicationSectionIII.objects.create( + application=application, + company_name=c["company_name"], + contact_person=c["contact_person"], + contact_no=c["contact_no"], + development_stage=data["development_stage"], + form_iii=files.get("form_iii"), + ) + + # Inventors (AssociatedWith → now Inventor) + total_percentage = 0 + for inv in inventors_data: + email = inv.get("institute_mail", "") + if not email: + raise ValidationError("Each inventor must have an institute_mail.") + try: + inv_user = User.objects.get(email=email) + except User.DoesNotExist: + raise NotFoundError(f"Inventor with email {email} not found in system.") + inv_applicant, _ = Applicant.objects.update_or_create( + user=inv_user, + defaults={ + "email": inv.get("personal_mail", inv_user.email), + "name": inv.get("name", inv_user.get_full_name()), + "mobile": inv.get("mobile", ""), + "address": inv.get("address", ""), + }, + ) + percentage = inv.get("percentage", 0) + # Convert percentage to float/int to handle string inputs from frontend + try: + percentage = float(percentage) if percentage else 0 + except (ValueError, TypeError): + raise ValidationError(f"Invalid percentage value for inventor: {percentage}") + + total_percentage += percentage + Inventor.objects.create( + application=application, + applicant=inv_applicant, + percentage_share=percentage, + ) + + # CRITICAL: Validate inventor shares sum to exactly 100% + if total_percentage != 100: + raise ValidationError(f"Inventor percentage shares must sum to exactly 100%. Current total: {total_percentage}%") + + # Create notifications for all inventors to review and consent + for inventor in Inventor.objects.filter(application=application): + _create_notification_for_applicant( + application, + NotificationType.CONSENT_REQUIRED, + "Inventor Consent Required", + f"Please review and provide consent for patent application '{application.title}'. " + f"Your percentage share is {inventor.percentage_share}%." + ) + + _audit(application, user, "Application Created - Pending Inventor Consent", "", ApplicationStatus.PENDING_INVENTOR_CONSENT) + return application + + +# ── UC-002: Assign to Director (PCC Admin) ────────────────────────────── + +@transaction.atomic +def assign_to_director(user, application_id, director_user_id=None): + """PCC Admin assigns an application to a Director for review.""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status not in (ApplicationStatus.SUBMITTED, ApplicationStatus.REVIEWED, ApplicationStatus.RESUBMITTED): + raise ValidationError("Application is not in a state that can be assigned to a director.") + + # CRITICAL VALIDATION: Check inventor requirements before director assignment + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot assign to director: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot assign to director: All inventors must give their consent before director assignment.") + + if director_user_id: + try: + director = User.objects.get(id=director_user_id) + except User.DoesNotExist: + raise NotFoundError("Director user not found.") + _check_director_conflict(director, application) + application.assigned_director = director + + prev = application.status + application.status = ApplicationStatus.UNDER_REVIEW + application.save() + _audit(application, user, "Assigned to Director", prev, application.status) + return application + + +# ── UC-003: Director reviews application ───────────────────────────────── + +@transaction.atomic +def director_review(user, application_id, decision, feedback=""): + """ + Director makes decision: Approve / Reject / Needs Revision (WF-003, BR-PMS-004/005). + """ + assert_director(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status != ApplicationStatus.FORWARDED: + raise ValidationError( + f"Application must be 'Forwarded for Director's Review'. Current: {application.status}" + ) + + _check_director_conflict(user, application) + + # CRITICAL VALIDATION: Check inventor requirements before any director decision + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot make director decision: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot make director decision: All inventors must give their consent before director review.") + + if decision not in ("Approve", "Reject", "Needs Revision"): + raise ValidationError("Decision must be 'Approve', 'Reject', or 'Needs Revision'.") + + # BR-PMS-005: rejection requires feedback ≥50 chars + if decision in ("Reject", "Needs Revision"): + if not feedback or len(feedback.strip()) < 50: + raise ValidationError("Feedback must be at least 50 characters for Reject / Needs Revision.") + + prev = application.status + + if decision == "Approve": + application.status = ApplicationStatus.APPROVED + application.decision_status = DecisionStatus.APPROVED + application.director_approval_date = now() + application.token_no = _generate_token(application) + elif decision == "Reject": + application.status = ApplicationStatus.REJECTED + application.decision_status = DecisionStatus.REJECTED + application.decision_date = now() + application.resubmission_deadline = now() + timedelta(days=REVISION_WINDOW_DAYS) + else: # Needs Revision + application.status = ApplicationStatus.NEEDS_REVISION + application.decision_status = DecisionStatus.NEEDS_REVISION + application.decision_date = now() + application.resubmission_deadline = now() + timedelta(days=REVISION_WINDOW_DAYS) + + application.director_feedback = feedback + application.save() + _audit(application, user, f"Director decision: {decision}", prev, application.status, feedback) + return application + + +# ── UC-004: Revise and resubmit (WF-201) ───────────────────────────────── + +@transaction.atomic +def resubmit_application(user, application_id, data, files): + """Applicant resubmits after revision within 60-day window.""" + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + applicant = assert_applicant(user, application) + + if application.status not in (ApplicationStatus.NEEDS_REVISION, ApplicationStatus.REJECTED): + raise ValidationError("Application is not in a revisable state.") + + # BR-PMS-016: 60-day window + if application.resubmission_deadline and now() > application.resubmission_deadline: + application.status = ApplicationStatus.EXPIRED + application.save() + _audit(application, user, "Resubmission expired", application.status, ApplicationStatus.EXPIRED) + raise ValidationError("The 60-day resubmission window has expired.") + + prev = application.status + + # NOTE: During resubmission, inventor data is LOCKED and cannot be changed + # Only technical content (Sections I, II, III) can be updated + # Inventor percentages and consent status remain from original submission + + # Update sections if data provided + if "title" in data: + application.title = data["title"] + + # Update Section I + sec1 = ApplicationSectionI.objects.filter(application=application).first() + if sec1: + for k, attr in [("area_of_invention", "area"), ("problem_statement", "problem"), + ("objective", "objective"), ("novelty", "novelty"), + ("advantages", "advantages"), ("ip_type", "type_of_ip"), + ("tested_experimentally", "is_tested"), ("applications", "applications")]: + if k in data: + setattr(sec1, attr, data[k]) + if files.get("poc_details"): + sec1.poc_details = files["poc_details"] + sec1.save() + + # Update Section II + sec2 = ApplicationSectionII.objects.filter(application=application).first() + if sec2: + for k in ["funding_details", "funding_source", "publication_details", + "mou_details", "research_details"]: + if k in data: + setattr(sec2, k, data[k]) + if files.get("source_file"): + sec2.source_agreement = files["source_file"] + if files.get("mou_file"): + sec2.mou_file = files["mou_file"] + sec2.save() + + application.status = ApplicationStatus.RESUBMITTED + application.decision_status = DecisionStatus.PENDING + application.submitted_date = now() + application.comments = data.get("comments", application.comments) + application.save() + + _audit(application, user, "Application resubmitted", prev, ApplicationStatus.RESUBMITTED) + return application + + +# ── UC-006: PCC Admin reviews application ───────────────────────────────── + +@transaction.atomic +def pcc_review_application(user, application_id, comments=""): + """PCC Admin marks application as reviewed (WF-002).""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status == ApplicationStatus.REVIEWED: + raise ValidationError("Application is already reviewed.") + + if application.status not in (ApplicationStatus.SUBMITTED, ApplicationStatus.UNDER_REVIEW, ApplicationStatus.RESUBMITTED): + raise ValidationError("Application cannot be reviewed in its current state.") + + # CRITICAL VALIDATION: Check inventor requirements before PCC Admin can process + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot process application: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot process application: All inventors must give their consent before PCC Admin review.") + + prev = application.status + application.status = ApplicationStatus.REVIEWED + application.reviewed_by_pcc_date = now() + if comments: + application.comments = comments + application.save() + + _audit(application, user, "PCC Admin reviewed", prev, ApplicationStatus.REVIEWED, comments) + return application + + +# ── UC-007: Forward to Director (PCC Admin) ────────────────────────────── + +@transaction.atomic +def forward_to_director(user, application_id, comments=""): + """PCC Admin forwards a reviewed application to Director.""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status == ApplicationStatus.FORWARDED: + raise ValidationError("Application is already forwarded.") + + if application.status not in [ApplicationStatus.REVIEWED, ApplicationStatus.SUBMITTED, ApplicationStatus.RESUBMITTED]: + raise ValidationError("Application must be reviewed or submitted before forwarding.") + + # CRITICAL VALIDATION: Check inventor requirements before forwarding to director + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot forward to director: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot forward to director: All inventors must give their consent before forwarding.") + + if comments and len(comments) > 1000: + raise ValidationError("Comments must be ≤ 1000 characters.") + + prev = application.status + application.status = ApplicationStatus.FORWARDED + application.forwarded_to_director_date = now() + if comments: + application.comments = comments + application.save() + + _audit(application, user, "Forwarded to Director", prev, ApplicationStatus.FORWARDED, comments) + return application + + +# ── UC-008: Request modification (PCC Admin) ───────────────────────────── + +@transaction.atomic +def request_modification(user, application_id, comments): + """Send application back for applicant edits (Needs Revision).""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status in [ApplicationStatus.DRAFT, ApplicationStatus.NEEDS_REVISION]: + raise ValidationError(f"Application is already in {application.status}.") + + if not comments or len(comments.strip()) < 10: + raise ValidationError("Comments are required (min 10 characters).") + if len(comments) > 1000: + raise ValidationError("Comments must be ≤ 1000 characters.") + + prev = application.status + application.status = ApplicationStatus.NEEDS_REVISION + application.decision_status = DecisionStatus.NEEDS_REVISION + application.comments = comments + from datetime import timedelta + application.resubmission_deadline = now() + timedelta(days=60) + application.save() + + try: + from notifications.signals import notify + + applicant_user = application.primary_applicant.user + notify.send(sender=user, recipient=applicant_user, + verb='requested modification for your patent application', + description=comments, target=application) + except Exception as e: + import logging + logging.getLogger(__name__).error("Could not send notification: %s", e) + + _audit(application, user, "Modification requested", prev, ApplicationStatus.NEEDS_REVISION, comments) + return application + + +# ── PCC Admin: Change ongoing application status ───────────────────────── + +ONGOING_DATE_MAP = { + ApplicationStatus.PATENTABILITY_CHECK_STARTED: "patentability_check_start_date", + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED: "patentability_check_completed_date", + ApplicationStatus.SEARCH_REPORT_GENERATED: "search_report_generated_date", + ApplicationStatus.PATENT_FILED: "patent_filed_date", + ApplicationStatus.PATENT_PUBLISHED: "patent_published_date", + ApplicationStatus.PATENT_GRANTED: "decision_date", + ApplicationStatus.PATENT_REFUSED: "decision_date", +} + + +@transaction.atomic +def change_status(user, application_id, next_status): + """PCC Admin advances an ongoing application through the pipeline.""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + _validate_transition(application.status, next_status) + + # CRITICAL VALIDATION: Check inventor requirements before advancing status + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot advance status: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot advance status: All inventors must give their consent before status advancement.") + + prev = application.status + application.status = next_status + + date_field = ONGOING_DATE_MAP.get(next_status) + if date_field: + setattr(application, date_field, now()) + + if next_status == ApplicationStatus.PATENT_GRANTED: + application.decision_status = DecisionStatus.APPROVED + elif next_status == ApplicationStatus.PATENT_REFUSED: + application.decision_status = DecisionStatus.REJECTED + + application.save() + _audit(application, user, f"Status changed to {next_status}", prev, next_status) + return application + + +# ── UC-014: Withdraw application ───────────────────────────────────────── + +NON_WITHDRAWABLE = { + ApplicationStatus.PATENT_FILED, ApplicationStatus.PATENT_PUBLISHED, + ApplicationStatus.PATENT_GRANTED, ApplicationStatus.PATENT_REFUSED, + ApplicationStatus.WITHDRAWN, ApplicationStatus.EXPIRED, +} + + +@transaction.atomic +def withdraw_application(user, application_id, reason=""): + """Applicant withdraws an application before filing.""" + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + assert_applicant(user, application) + + if application.status in NON_WITHDRAWABLE: + raise ValidationError("Application cannot be withdrawn in its current state.") + + prev = application.status + application.status = ApplicationStatus.WITHDRAWN + application.withdrawn_date = now() + application.comments = reason or application.comments + application.save() + + _audit(application, user, "Application withdrawn", prev, ApplicationStatus.WITHDRAWN, reason) + return application + + +# ── UC-008 / WF-301: Budget management ─────────────────────────────────── + +@transaction.atomic +def create_or_update_budget(user, application_id, filing_cost=0, attorney_fees=0, + administrative_cost=0, remarks=""): + """PCC Admin creates / updates budget for an application.""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + budget, created = Budget.objects.update_or_create( + application=application, + defaults={ + "filing_cost": filing_cost, + "attorney_fees": attorney_fees, + "administrative_cost": administrative_cost, + "remarks": remarks, + }, + ) + + total = budget.total_cost + if total > BUDGET_THRESHOLD: + budget.decision = BudgetDecision.ESCALATED + else: + budget.decision = BudgetDecision.APPROVED_PCC + budget.decision_by = user + budget.decision_date = now() + budget.save() + + _audit(application, user, "Budget updated", "", "", f"Total: ₹{total}") + return budget + + +@transaction.atomic +def director_budget_decision(user, application_id, approve, remarks=""): + """Director approves / denies an escalated budget.""" + assert_director(user) + try: + budget = Budget.objects.select_related("application").get(application_id=application_id) + except Budget.DoesNotExist: + raise NotFoundError("Budget not found for this application.") + + if budget.decision != BudgetDecision.ESCALATED: + raise ValidationError("Budget is not pending director approval.") + + # CRITICAL VALIDATION: Check inventor requirements before budget decision + application = budget.application + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot approve budget: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot approve budget: All inventors must give their consent before budget approval.") + + budget.decision = BudgetDecision.APPROVED_DIRECTOR if approve else BudgetDecision.DENIED + budget.decision_by = user + budget.decision_date = now() + budget.remarks = remarks + budget.save() + + _audit(budget.application, user, f"Budget {'approved' if approve else 'denied'} by Director") + return budget + + +# ── Communication Log (replaces Attorney interactions) ─────────────────── + +@transaction.atomic +def add_communication_log(user, application_id, direction, subject, body="", + external_party_name="", external_party_email="", + attachment=None, confidentiality_level="Internal"): + """PCC Admin logs a communication with external party (BR-PMS-019).""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + log = CommunicationLog.objects.create( + application=application, + logged_by=user, + direction=direction, + subject=subject, + body=body, + external_party_name=external_party_name, + external_party_email=external_party_email, + attachment=attachment, + confidentiality_level=confidentiality_level, + ) + _audit(application, user, f"Communication logged: {subject}") + return log + + +# ── UC-006: Assign Attorney (PCC Admin fills external attorney details) ── + +@transaction.atomic +def assign_attorney(user, application_id, attorney_name, attorney_email="", + attorney_phone="", attorney_firm="", specialization="", + remarks="", engagement_proof=None): + """ + PCC Admin assigns an external attorney to an approved application (UC-006, BR-PMS-007). + The attorney is external — PCC Admin enters their details and uploads proof. + """ + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status != ApplicationStatus.APPROVED: + raise ValidationError( + "Attorney can only be assigned to an approved application (BR-PMS-004)." + ) + + # CRITICAL VALIDATION: Check inventor requirements before attorney assignment + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot assign attorney: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot assign attorney: All inventors must give their consent before attorney assignment.") + + if not attorney_name or len(attorney_name.strip()) < 2: + raise ValidationError("Attorney name is required (min 2 characters).") + + # Create or update the assignment + assignment, created = AttorneyAssignment.objects.update_or_create( + application=application, + defaults={ + "attorney_name": attorney_name.strip(), + "attorney_email": attorney_email.strip(), + "attorney_phone": attorney_phone.strip(), + "attorney_firm": attorney_firm.strip(), + "specialization": specialization.strip(), + "assigned_by": user, + "remarks": remarks, + "engagement_proof": engagement_proof, + "is_active": True, + }, + ) + + action = "Attorney assigned" if created else "Attorney assignment updated" + _audit(application, user, action, "", "", + f"Attorney: {attorney_name}, Firm: {attorney_firm}") + return assignment + + +# ── UC-007: Record Patentability Assessment (PCC Admin enters attorney opinion) ── + +@transaction.atomic +def record_patentability_assessment(user, application_id, recommendation, + opinion_summary="", novelty_score=0, + non_obviousness_score=0, utility_score=0, + search_completeness=0, prior_art_references="", + assessed_by_attorney="", attorney_report=None, + assessment_date=None): + """ + PCC Admin records the external attorney's patentability assessment (UC-007, BR-PMS-014). + Must have valid scores and recommendation before filing can proceed. + """ + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + # Assessment is only valid for applications in patentability check stages + valid_statuses = [ + ApplicationStatus.APPROVED, + ApplicationStatus.PATENTABILITY_CHECK_STARTED, + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED, + ] + if application.status not in valid_statuses: + raise ValidationError( + f"Assessment can only be recorded for applications in status: " + f"{[s.value for s in valid_statuses]}. Current: {application.status}" + ) + + # CRITICAL VALIDATION: Check inventor requirements before patentability assessment + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot record patentability assessment: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot record patentability assessment: All inventors must give their consent before assessment.") + + # BR-PMS-014: Validate recommendation + valid_recommendations = [r.value for r in PatentabilityRecommendation] + if recommendation not in valid_recommendations: + raise ValidationError( + f"Recommendation must be one of: {valid_recommendations}" + ) + + # BR-PMS-014: Validate scores (0-100) + for name, score in [("novelty_score", novelty_score), + ("non_obviousness_score", non_obviousness_score), + ("utility_score", utility_score), + ("search_completeness", search_completeness)]: + try: + val = float(score) + except (TypeError, ValueError): + raise ValidationError(f"{name} must be a numeric value.") + if val < 0 or val > 100: + raise ValidationError(f"{name} must be between 0 and 100.") + + # BR-PMS-014: opinion_summary is required + if not opinion_summary or len(opinion_summary.strip()) < 20: + raise ValidationError("Opinion summary must be at least 20 characters (BR-PMS-014).") + + assessment, created = PatentabilityAssessment.objects.update_or_create( + application=application, + defaults={ + "assessed_by_attorney": assessed_by_attorney.strip(), + "novelty_score": novelty_score, + "non_obviousness_score": non_obviousness_score, + "utility_score": utility_score, + "search_completeness": search_completeness, + "recommendation": recommendation, + "opinion_summary": opinion_summary.strip(), + "prior_art_references": prior_art_references.strip(), + "attorney_report": attorney_report, + "recorded_by": user, + "assessment_date": assessment_date or now(), + }, + ) + + action = "Patentability assessment recorded" if created else "Patentability assessment updated" + _audit(application, user, action, "", "", + f"Recommendation: {recommendation}, Novelty: {novelty_score}") + return assessment + + +# ── UC-009 / WF-601: Record Filing with Patent Office ─────────────────── + +@transaction.atomic +def record_filing(user, application_id, filing_office="Indian Patent Office", + jurisdiction="India", external_filing_id="", + filing_date=None, confirmation_proof=None, + international_filing_justification="", remarks=""): + """ + PCC Admin logs the filing of a patent with a patent office (UC-009, BR-PMS-017, WF-601). + International filings require justification. + """ + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + # Filing can only happen for applications at SEARCH_REPORT_GENERATED or PATENT_FILED status + if application.status not in (ApplicationStatus.SEARCH_REPORT_GENERATED, + ApplicationStatus.PATENT_FILED): + raise ValidationError( + "Filing can only be recorded when status is 'Search Report Generated' or 'Patent Filed'." + ) + + # CRITICAL VALIDATION: Check inventor requirements before patent filing + try: + validate_inventor_shares(application) + except ValidationError: + raise ValidationError("Cannot record patent filing: Inventor percentage shares must sum to exactly 100%.") + + if not check_all_consents(application): + raise ValidationError("Cannot record patent filing: All inventors must give their consent before filing.") + + # BR-PMS-017: International filing requires justification + if filing_office.strip().lower() != "indian patent office" and jurisdiction.strip().lower() != "india": + if not international_filing_justification or len(international_filing_justification.strip()) < 10: + raise ValidationError( + "International filings require a justification of at least 10 characters (BR-PMS-017)." + ) + + filing, created = FilingRecord.objects.update_or_create( + application=application, + defaults={ + "filing_office": filing_office.strip(), + "jurisdiction": jurisdiction.strip(), + "external_filing_id": external_filing_id.strip(), + "filing_date": filing_date or now(), + "confirmation_proof": confirmation_proof, + "international_filing_justification": international_filing_justification.strip(), + "filed_by": user, + "remarks": remarks, + }, + ) + + # Auto-advance status to PATENT_FILED if at SEARCH_REPORT_GENERATED + if application.status == ApplicationStatus.SEARCH_REPORT_GENERATED: + prev = application.status + application.status = ApplicationStatus.PATENT_FILED + application.patent_filed_date = filing.filing_date or now() + application.save() + _audit(application, user, "Status changed to Patent Filed", prev, + ApplicationStatus.PATENT_FILED, f"Filing ID: {external_filing_id}") + + action = "Filing recorded" if created else "Filing record updated" + _audit(application, user, action, "", "", + f"Office: {filing_office}, ID: {external_filing_id}") + return filing + + +# =========================================================================== +# FEATURE 1: LODGE FORMAL APPEAL +# =========================================================================== + +@transaction.atomic +def lodge_appeal(user, application_id, reason): + """ + Applicant lodges a formal appeal against a rejected application. + Only allowed within 60 days of rejection. + """ + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + applicant = assert_applicant(user, application) + + if application.status != ApplicationStatus.REJECTED: + raise ValidationError("Appeals can only be lodged for rejected applications.") + + # Check if within appeal window (60 days from rejection) + if application.decision_date: + appeal_deadline = application.decision_date + timedelta(days=REVISION_WINDOW_DAYS) + if now() > appeal_deadline: + raise ValidationError("The 60-day appeal window has expired.") + + if not reason or len(reason.strip()) < 50: + raise ValidationError("Appeal reason must be at least 50 characters.") + + prev = application.status + application.status = ApplicationStatus.APPEAL + application.appeal_date = now() + application.appeal_reason = reason.strip() + application.save() + + # Create notification for PCC Admin + _create_notification_for_role( + application, + NotificationType.ACTION_REQUIRED, + "New Appeal Filed", + f"An appeal has been filed for application: {application.title}", + is_pcc_admin + ) + + _audit(application, user, "Appeal lodged", prev, ApplicationStatus.APPEAL, reason[:100]) + return application + + +@transaction.atomic +def pcc_review_appeal(user, application_id): + """PCC Admin reviews and forwards appeal to Director.""" + assert_pcc_admin(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status != ApplicationStatus.APPEAL: + raise ValidationError("Application is not in Appeal status.") + + prev = application.status + application.status = ApplicationStatus.APPEAL_UNDER_REVIEW + application.save() + + _audit(application, user, "Appeal forwarded for Director review", prev, ApplicationStatus.APPEAL_UNDER_REVIEW) + return application + + +@transaction.atomic +def director_appeal_decision(user, application_id, approve, feedback=""): + """Director decides on the appeal - approve or reject.""" + assert_director(user) + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status != ApplicationStatus.APPEAL_UNDER_REVIEW: + raise ValidationError("Application is not under appeal review.") + + _check_director_conflict(user, application) + + if not approve and (not feedback or len(feedback.strip()) < 50): + raise ValidationError("Feedback must be at least 50 characters when rejecting an appeal.") + + prev = application.status + + if approve: + application.status = ApplicationStatus.APPEAL_APPROVED + application.appeal_decision = "Approved" + # Create notification for applicant + _create_notification_for_applicant( + application, + NotificationType.APPEAL_UPDATE, + "Appeal Approved", + f"Your appeal for '{application.title}' has been approved. The application will be reconsidered." + ) + else: + application.status = ApplicationStatus.APPEAL_REJECTED + application.appeal_decision = "Rejected" + _create_notification_for_applicant( + application, + NotificationType.APPEAL_UPDATE, + "Appeal Rejected", + f"Your appeal for '{application.title}' has been rejected. Reason: {feedback[:100]}" + ) + + application.appeal_decision_date = now() + application.appeal_decision_by = user + application.director_feedback = feedback + application.save() + + _audit(application, user, f"Appeal {'approved' if approve else 'rejected'}", prev, application.status, feedback) + return application + + +# =========================================================================== +# FEATURE 2: INVENTOR CONSENT & SHARE VALIDATION +# =========================================================================== + +def validate_inventor_shares(application): + """Validate that inventor shares sum to exactly 100%.""" + total_share = Inventor.objects.filter(application=application).aggregate( + total=models.Sum('percentage_share') + )['total'] or 0 + if total_share != 100: + raise ValidationError(f"Inventor shares must sum to 100%. Current total: {total_share}%") + return True + + +def check_all_consents(application): + """Check if all inventors have given consent.""" + inventors = Inventor.objects.filter(application=application) + if not inventors.exists(): + return False + return all(inv.has_consent for inv in inventors) + + +@transaction.atomic +def give_inventor_consent(user, application_id): + """Inventor gives consent for the application.""" + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + try: + applicant = Applicant.objects.get(user=user) + except Applicant.DoesNotExist: + raise UnauthorizedError("User is not a registered applicant.") + + try: + inventor = Inventor.objects.get(applicant=applicant, application=application) + except Inventor.DoesNotExist: + raise UnauthorizedError("You are not an inventor on this application.") + + if inventor.has_consent: + raise ValidationError("You have already given consent.") + + inventor.has_consent = True + inventor.consent_date = now() + inventor.save() + + _audit(application, user, f"Inventor consent given by {applicant.name}") + + # Create notification for primary applicant and auto-transition if all consents received + if check_all_consents(application): + # Auto-transition from PENDING_INVENTOR_CONSENT to SUBMITTED + if application.status == ApplicationStatus.PENDING_INVENTOR_CONSENT: + prev_status = application.status + application.status = ApplicationStatus.SUBMITTED + application.save() + + _audit(application, user, "Auto-transitioned to Submitted - All Consents Received", prev_status, ApplicationStatus.SUBMITTED) + + # Notify primary applicant + _create_notification_for_applicant( + application, + NotificationType.STATUS_CHANGE, + "Application Submitted - All Consents Received", + f"All inventors have given consent for '{application.title}'. " + f"Your application has been automatically submitted and is now awaiting PCC Admin review." + ) + else: + # For other statuses, just notify + _create_notification_for_applicant( + application, + NotificationType.STATUS_CHANGE, + "All Inventor Consents Received", + f"All inventors have given consent for '{application.title}'." + ) + + return inventor + + +@transaction.atomic +def revoke_inventor_consent(user, application_id): + """Inventor revokes consent (only allowed in draft/needs revision status).""" + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + if application.status not in [ApplicationStatus.DRAFT, ApplicationStatus.NEEDS_REVISION]: + raise ValidationError("Consent can only be revoked when application is in Draft or Needs Revision status.") + + try: + applicant = Applicant.objects.get(user=user) + except Applicant.DoesNotExist: + raise UnauthorizedError("User is not a registered applicant.") + + try: + inventor = Inventor.objects.get(applicant=applicant, application=application) + except Inventor.DoesNotExist: + raise UnauthorizedError("You are not an inventor on this application.") + + inventor.has_consent = False + inventor.consent_date = None + inventor.save() + + _audit(application, user, f"Inventor consent revoked by {applicant.name}") + return inventor + + +# =========================================================================== +# FEATURE 4: DEADLINE & ALERT SYSTEM (Notifications) +# =========================================================================== + +def _create_notification_for_applicant(application, notification_type, title, message, deadline_date=None): + """Create notification for the primary applicant.""" + if application.primary_applicant and application.primary_applicant.user: + PatentNotification.objects.create( + recipient=application.primary_applicant.user, + application=application, + notification_type=notification_type, + title=title, + message=message, + deadline_date=deadline_date, + action_url=f"/patent/applicant/applications/{application.id}", + ) + + +def _create_notification_for_role(application, notification_type, title, message, role_check_func): + """Create notification for users with a specific role.""" + from django.contrib.auth.models import User + for user in User.objects.filter(is_active=True): + if role_check_func(user): + PatentNotification.objects.create( + recipient=user, + application=application, + notification_type=notification_type, + title=title, + message=message, + action_url=f"/patent/pccAdmin/applications/{application.id}", + ) + + +def get_user_notifications(user, unread_only=False): + """Get notifications for a user.""" + qs = PatentNotification.objects.filter(recipient=user) + if unread_only: + qs = qs.filter(is_read=False) + return qs + + +@transaction.atomic +def mark_notification_read(user, notification_id): + """Mark a notification as read.""" + try: + notification = PatentNotification.objects.get(id=notification_id, recipient=user) + except PatentNotification.DoesNotExist: + raise NotFoundError("Notification not found.") + notification.is_read = True + notification.save() + return notification + + +@transaction.atomic +def mark_all_notifications_read(user): + """Mark all notifications as read for a user.""" + return PatentNotification.objects.filter(recipient=user, is_read=False).update(is_read=True) + + +def check_approaching_deadlines(): + """ + Check for applications with approaching deadlines and create notifications. + Should be called periodically (e.g., daily via celery task). + """ + from datetime import timedelta + + # Check resubmission deadlines (7 days before) + warning_date = now() + timedelta(days=7) + apps_with_deadlines = Application.objects.filter( + status__in=[ApplicationStatus.NEEDS_REVISION, ApplicationStatus.REJECTED], + resubmission_deadline__lte=warning_date, + resubmission_deadline__gt=now() + ) + + for app in apps_with_deadlines: + # Check if notification already sent + existing = PatentNotification.objects.filter( + application=app, + notification_type=NotificationType.DEADLINE_APPROACHING, + created_at__gte=now() - timedelta(days=1) + ).exists() + + if not existing: + days_left = (app.resubmission_deadline - now()).days + _create_notification_for_applicant( + app, + NotificationType.DEADLINE_APPROACHING, + f"Deadline in {days_left} days", + f"Your resubmission deadline for '{app.title}' is approaching. Please submit before {app.resubmission_deadline.strftime('%Y-%m-%d')}.", + deadline_date=app.resubmission_deadline + ) + + # Check for expired deadlines + expired_apps = Application.objects.filter( + status__in=[ApplicationStatus.NEEDS_REVISION, ApplicationStatus.REJECTED], + resubmission_deadline__lt=now() + ) + + for app in expired_apps: + existing = PatentNotification.objects.filter( + application=app, + notification_type=NotificationType.DEADLINE_EXPIRED + ).exists() + + if not existing: + app.status = ApplicationStatus.EXPIRED + app.save() + _create_notification_for_applicant( + app, + NotificationType.DEADLINE_EXPIRED, + "Resubmission Deadline Expired", + f"The resubmission deadline for '{app.title}' has expired. The application has been marked as expired." + ) + + +# =========================================================================== +# FEATURE 5: DOCUMENT VERSION CONTROL +# =========================================================================== + +@transaction.atomic +def upload_document(user, application_id, document_type, title, file, description=""): + """Upload a new version of a document.""" + try: + application = Application.objects.get(id=application_id) + except Application.DoesNotExist: + raise NotFoundError("Application not found.") + + # Check authorization (must be inventor or PCC admin) + is_inventor = False + try: + applicant = Applicant.objects.get(user=user) + is_inventor = Inventor.objects.filter(applicant=applicant, application=application).exists() + except Applicant.DoesNotExist: + pass + + if not is_inventor and not is_pcc_admin(user): + raise UnauthorizedError("Only inventors or PCC Admin can upload documents.") + + doc = ApplicationDocument.objects.create( + application=application, + document_type=document_type.strip(), + title=title.strip(), + file=file, + description=description.strip(), + uploaded_by=user, + ) + + _audit(application, user, f"Document uploaded: {title} v{doc.version}") + return doc + + +def get_document_versions(application_id, document_type=None): + """Get all document versions for an application.""" + qs = ApplicationDocument.objects.filter(application_id=application_id) + if document_type: + qs = qs.filter(document_type=document_type) + return qs + + +def get_current_documents(application_id): + """Get only the current (latest) version of each document type.""" + return ApplicationDocument.objects.filter( + application_id=application_id, + is_current=True + ) + + +# =========================================================================== +# FEATURE 6: SEARCH & GLOBAL FILTERING +# =========================================================================== + +def search_applications( + user, + query="", + status_filter=None, + date_from=None, + date_to=None, + department_filter=None, + decision_filter=None, + limit=50, + offset=0 +): + """ + Search and filter applications based on various criteria. + Returns applications the user has access to. + """ + from django.db.models import Q + + qs = Application.objects.all() + + # Role-based filtering + if is_pcc_admin(user) or is_director(user): + pass # Can see all applications + else: + # Applicant can only see their own applications + try: + applicant = Applicant.objects.get(user=user) + app_ids = Inventor.objects.filter(applicant=applicant).values_list("application_id", flat=True) + qs = qs.filter(id__in=app_ids) + except Applicant.DoesNotExist: + return [] + + # Text search (title, token_no, comments) + if query: + qs = qs.filter( + Q(title__icontains=query) | + Q(token_no__icontains=query) | + Q(comments__icontains=query) | + Q(primary_applicant__name__icontains=query) + ) + + # Status filter + if status_filter: + if isinstance(status_filter, list): + qs = qs.filter(status__in=status_filter) + else: + qs = qs.filter(status=status_filter) + + # Decision filter + if decision_filter: + qs = qs.filter(decision_status=decision_filter) + + # Date range filter + if date_from: + qs = qs.filter(submitted_date__gte=date_from) + if date_to: + qs = qs.filter(submitted_date__lte=date_to) + + # Department filter + if department_filter: + # Filter by primary applicant's department + from applications.globals.models import ExtraInfo + user_ids = ExtraInfo.objects.filter( + department__name__icontains=department_filter + ).values_list("user_id", flat=True) + applicant_ids = Applicant.objects.filter(user_id__in=user_ids).values_list("id", flat=True) + qs = qs.filter(primary_applicant_id__in=applicant_ids) + + total_count = qs.count() + results = qs.select_related("primary_applicant")[offset:offset + limit] + + return { + "total": total_count, + "applications": [ + { + "id": app.id, + "title": app.title, + "status": app.status, + "decision_status": app.decision_status, + "token_no": app.token_no, + "submitted_date": app.submitted_date, + "primary_applicant": app.primary_applicant.name if app.primary_applicant else None, + } + for app in results + ] + } + + +# =========================================================================== +# FEATURE 7: REPORTING & ANALYTICS +# =========================================================================== + +def get_analytics_summary(year=None, department=None): + """Get comprehensive analytics summary.""" + from django.db.models import Count, Avg + from django.db.models.functions import TruncMonth + + qs = Application.objects.all() + + if year: + qs = qs.filter(submitted_date__year=year) + + if department: + from applications.globals.models import ExtraInfo + user_ids = ExtraInfo.objects.filter( + department__name__icontains=department + ).values_list("user_id", flat=True) + applicant_ids = Applicant.objects.filter(user_id__in=user_ids).values_list("id", flat=True) + qs = qs.filter(primary_applicant_id__in=applicant_ids) + + # Status distribution + status_dist = list(qs.values("status").annotate(count=Count("id")).order_by("-count")) + + # Decision distribution + decision_dist = list(qs.values("decision_status").annotate(count=Count("id")).order_by("-count")) + + # Monthly submissions + monthly_submissions = list( + qs.filter(submitted_date__isnull=False) + .annotate(month=TruncMonth("submitted_date")) + .values("month") + .annotate(count=Count("id")) + .order_by("month") + ) + + # Calculate approval rate + total = qs.count() + approved = qs.filter(decision_status=DecisionStatus.APPROVED).count() + rejected = qs.filter(decision_status=DecisionStatus.REJECTED).count() + pending = qs.filter(decision_status=DecisionStatus.PENDING).count() + + approval_rate = (approved / total * 100) if total > 0 else 0 + + # Department-wise distribution + dept_dist = [] + from applications.globals.models import ExtraInfo, DepartmentInfo + for dept in DepartmentInfo.objects.all(): + user_ids = ExtraInfo.objects.filter(department=dept).values_list("user_id", flat=True) + applicant_ids = Applicant.objects.filter(user_id__in=user_ids).values_list("id", flat=True) + count = qs.filter(primary_applicant_id__in=applicant_ids).count() + if count > 0: + dept_dist.append({"department": dept.name, "count": count}) + + return { + "total_applications": total, + "approved": approved, + "rejected": rejected, + "pending": pending, + "approval_rate": round(approval_rate, 2), + "status_distribution": status_dist, + "decision_distribution": decision_dist, + "monthly_submissions": [ + {"month": item["month"].strftime("%Y-%m") if item["month"] else None, "count": item["count"]} + for item in monthly_submissions + ], + "department_distribution": dept_dist, + } diff --git a/FusionIIIT/applications/patent_system/tests/__init__.py b/FusionIIIT/applications/patent_system/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/patent_system/tests/base.py b/FusionIIIT/applications/patent_system/tests/base.py new file mode 100644 index 000000000..9f7e5ddca --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/base.py @@ -0,0 +1,341 @@ +import json +import traceback +import uuid + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from rest_framework.test import APIClient + +from applications.globals.models import DepartmentInfo, Designation, ExtraInfo, HoldsDesignation + +User = get_user_model() + + +class BaseTestCase(TestCase): + def setUp(self): + # Use DRF client because the patent_system API is protected by TokenAuthentication. + self.client = APIClient() + self.API_PREFIX = "/patentsystem/" + + # Minimal shared fixtures + self.department, _ = DepartmentInfo.objects.get_or_create(name="CSE") + + # Core users used across tests + self.applicant_user = self._create_user( + username="applicant1", + email="applicant1@example.com", + user_type="faculty", + ) + self.coinventor_user = self._create_user( + username="inventor2", + email="inventor2@example.com", + user_type="faculty", + ) + self.outsider_user = self._create_user( + username="outsider", + email="outsider@example.com", + user_type="student", + ) + + # Role users (designation-backed) + self.pcc_admin_user = self._create_user( + username="pccadmin", + email="pccadmin@example.com", + user_type="staff", + designation_name="pcc_admin", + ) + self.director_user = self._create_user( + username="director", + email="director@example.com", + user_type="faculty", + designation_name="director", + ) + + # Default to no authentication (tests should opt-in) + self.logout() + + # ----------------------------- + # 👤 FIXTURE HELPERS + # ----------------------------- + def _create_user(self, username, email, user_type, designation_name=None): + user, created = User.objects.get_or_create(username=username, defaults={"email": email}) + if not created and user.email != email: + user.email = email + user.set_password("pass") + user.save() + + # ExtraInfo is required by token generation and department-based logic. + # NOTE: ExtraInfo.id is (in this codebase) a primary key. + # Do not overwrite it for an existing record; doing so can force an + # INSERT and trip the unique constraint on user_id (especially with --keepdb). + extra_defaults = { + "user_type": user_type, + "department": self.department, + } + extra_info, extra_created = ExtraInfo.objects.get_or_create( + user=user, + defaults={ + "id": (username[:3].upper() + uuid.uuid4().hex[:6]).upper(), + **extra_defaults, + }, + ) + if not extra_created: + changed = False + for key, value in extra_defaults.items(): + if getattr(extra_info, key) != value: + setattr(extra_info, key, value) + changed = True + if changed: + extra_info.save() + + if designation_name: + designation, _ = Designation.objects.get_or_create( + name=designation_name, + defaults={"full_name": designation_name, "type": "administrative"}, + ) + HoldsDesignation.objects.get_or_create( + user=user, + working=user, + designation=designation, + ) + + return user + + # ----------------------------- + # 🔐 AUTH METHODS (FIXED) + # ----------------------------- + def login_as_applicant(self): + self.client.force_authenticate(user=self.applicant_user) + return self.applicant_user + + def login_as_coinventor(self): + self.client.force_authenticate(user=self.coinventor_user) + return self.coinventor_user + + def login_as_outsider(self): + self.client.force_authenticate(user=self.outsider_user) + return self.outsider_user + + def login_as_pcc_admin(self): + self.client.force_authenticate(user=self.pcc_admin_user) + return self.pcc_admin_user + + def login_as_director(self): + self.client.force_authenticate(user=self.director_user) + return self.director_user + + def logout(self): + # DRF client auth + self.client.force_authenticate(user=None) + + # ----------------------------- + # 🧰 COMMON PAYLOADS + # ----------------------------- + def make_submit_payload(self, *, title="Test Patent", inventor_shares=None): + """Build a minimal valid payload for UC-001 submit_application service.""" + if inventor_shares is None: + inventor_shares = [ + (self.applicant_user, 50), + (self.coinventor_user, 50), + ] + + inventors = [] + for user, pct in inventor_shares: + inventors.append( + { + "name": user.get_full_name() or user.username, + "institute_mail": user.email, + "personal_mail": user.email, + "mobile": "9999999999", + "address": "Campus", + "percentage": pct, + } + ) + + return { + "title": title, + "inventors": inventors, + "area_of_invention": "AI", + "problem_statement": "Problem", + "objective": "Objective", + "ip_type": "Patent", + "novelty": "Novelty", + "advantages": "Advantages", + "tested_experimentally": False, + "applications": "Use cases", + "funding_details": "Self", + "funding_source": "Institute", + "publication_details": "None", + "mou_details": "None", + "research_details": "Details", + "company_details": [ + { + "company_name": "Acme", + "contact_person": "Alice", + "contact_no": "9999999999", + } + ], + "development_stage": "Embryonic", + } + + def post_submit_application(self, payload): + """POST UC-001 endpoint; returns (response, application_id|None).""" + url = self.API_PREFIX + "applicant/applications/submit/" + resp = self.client.post(url, {"json_data": json.dumps(payload)}, format="multipart") + app_id = None + try: + app_id = resp.json().get("application_id") + except Exception: + app_id = None + return resp, app_id + + def post_give_consent(self, application_id): + url = self.API_PREFIX + f"applicant/applications/{application_id}/consent/" + return self.client.post(url, {}, format="json") + + # ----------------------------- + # 🧠 TEST WRAPPER (SAFE) + # ----------------------------- + def _callTestMethod(self, method): + try: + method() + + except AssertionError: + if not getattr(self, "_results", None): + self._record_result( + getattr(self, "_test_id", "") or self.id(), + getattr(self, "_scenario", "") or "Assertion failed", + "Fail", + actual="Assertion failed", + evidence=traceback.format_exc(), + ) + raise + + except Exception as exc: + if not getattr(self, "_results", None): + self._record_result( + getattr(self, "_test_id", "") or self.id(), + getattr(self, "_scenario", "") or "Unhandled exception", + "Error", + actual=str(exc), + evidence=traceback.format_exc(), + ) + raise AssertionError(str(exc)) + + else: + if not getattr(self, "_results", None): + self._record_result( + getattr(self, "_test_id", "") or self.id(), + getattr(self, "_scenario", "") or "Completed", + "Pass", + actual="OK", + evidence="", + ) + + # ----------------------------- + # 📊 RESULT RECORDING + # ----------------------------- + def _record_result(self, test_id="", scenario="", status="Pass", actual="", evidence=""): + + # Backward compatibility handling + if ( + scenario in {"Pass", "Fail", "Error", "Partial"} + and test_id + and not str(test_id).startswith(("PMS-UC-", "PMS-WF-", "BR-")) + ): + test_id, scenario, status = getattr(self, "_test_id", ""), str(test_id), str(scenario) + + if test_id: + self._test_id = str(test_id) + + self._scenario = str(scenario) + + entry = { + "status": status, + "actual": actual, + "evidence": evidence + } + + if not hasattr(self, "_results") or self._results is None: + self._results = [entry] + elif len(self._results) == 0: + self._results = [entry] + else: + self._results[0] = entry + + # ID mapping for summary + normalized_test_id = str(test_id or getattr(self, "_test_id", "") or "") + parts = normalized_test_id.split("-") + + if normalized_test_id.startswith("PMS-UC-") and len(parts) >= 3 and not getattr(self, "_uc_id", ""): + self._uc_id = "-".join(parts[:3]) + elif normalized_test_id.startswith("BR-") and len(parts) >= 3 and not getattr(self, "_br_id", ""): + self._br_id = "-".join(parts[:3]) + elif normalized_test_id.startswith("PMS-WF-") and len(parts) >= 3 and not getattr(self, "_wf_id", ""): + self._wf_id = "-".join(parts[:3]) + + # ----------------------------- + # 🔄 WORKFLOW HELPERS + # ----------------------------- + def _add_step(self, step_no, action, expected, actual, passed): + if not hasattr(self, "_steps"): + self._steps = [] + + self._steps.append({ + "step_no": step_no, + "action": action, + "expected": expected, + "actual": actual, + "status": "Pass" if passed else "Fail", + }) + + def _all_steps_passed(self): + steps = getattr(self, "_steps", []) or [] + return all(step.get("status") == "Pass" for step in steps) + + # ----------------------------- + # 🌐 SAFE API METHODS + # ----------------------------- + def api_get(self, url, *, expected_status=None, query=None): + resp = self.client.get(url, data=query or {}, format="json") + if expected_status is not None: + self.assertEqual(resp.status_code, expected_status, msg=getattr(resp, "content", b"")[:500]) + return resp + + def api_post(self, url, data=None, *, expected_status=None, as_json=True): + fmt = "json" if as_json else "multipart" + resp = self.client.post(url, data or {}, format=fmt) + if expected_status is not None: + self.assertEqual(resp.status_code, expected_status, msg=getattr(resp, "content", b"")[:500]) + return resp + + def api_patch(self, url, data=None, *, expected_status=None): + resp = self.client.patch(url, data or {}, format="json") + if expected_status is not None: + self.assertEqual(resp.status_code, expected_status, msg=getattr(resp, "content", b"")[:500]) + return resp + + def api_put(self, url, data=None, *, expected_status=None): + resp = self.client.put(url, data or {}, format="json") + if expected_status is not None: + self.assertEqual(resp.status_code, expected_status, msg=getattr(resp, "content", b"")[:500]) + return resp + + def api_delete(self, url, data=None, *, expected_status=None): + resp = self.client.delete(url, data or {}, format="json") + if expected_status is not None: + self.assertEqual(resp.status_code, expected_status, msg=getattr(resp, "content", b"")[:500]) + return resp + + +class UCTestBase(BaseTestCase): + pass + + +class BRTestBase(BaseTestCase): + pass + + +class WFTestBase(BaseTestCase): + pass \ No newline at end of file diff --git a/FusionIIIT/applications/patent_system/tests/conftest.py b/FusionIIIT/applications/patent_system/tests/conftest.py new file mode 100644 index 000000000..4b496f21f --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/conftest.py @@ -0,0 +1,10 @@ +""" +conftest.py — Initial setup scaffold. +Customize this file with your module's specific logic. +""" +from django.test import TestCase + +class BaseModuleTestCase(TestCase): + @classmethod + def setUpTestData(cls): + pass # Add your module setup here diff --git a/FusionIIIT/applications/patent_system/tests/reports/Artifact_Evaluation.csv b/FusionIIIT/applications/patent_system/tests/reports/Artifact_Evaluation.csv new file mode 100644 index 000000000..bbf256ae7 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/reports/Artifact_Evaluation.csv @@ -0,0 +1,40 @@ +Artifact_ID,Type,Status,Details +PMS-UC-001,Use Case,Implemented Correctly,3/3 passed +PMS-UC-002,Use Case,Implemented Correctly,3/3 passed +PMS-UC-003,Use Case,Implemented Correctly,3/3 passed +PMS-UC-004,Use Case,Partially Implemented,1/3 passed +PMS-UC-005,Use Case,Partially Implemented,1/3 passed +PMS-UC-006,Use Case,Partially Implemented,2/3 passed +PMS-UC-007,Use Case,Implemented Correctly,3/3 passed +PMS-UC-008,Use Case,Implemented Correctly,3/3 passed +PMS-UC-009,Use Case,Implemented Correctly,3/3 passed +PMS-UC-010,Use Case,Implemented Correctly,3/3 passed +PMS-UC-014,Use Case,Implemented Correctly,3/3 passed +PMS-UC-015,Use Case,Implemented Correctly,3/3 passed +PMS-UC-016,Use Case,Implemented Correctly,3/3 passed +PMS-UC-018,Use Case,Implemented Correctly,3/3 passed +PMS-UC-019,Use Case,Incorrectly Implemented,0/3 passed +PMS-UC-020,Use Case,Implemented Correctly,3/3 passed +BR-PMS-001,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-002,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-003,Business Rule,Partially Enforced,1/2 passed +BR-PMS-004,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-005,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-006,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-007,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-008,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-009,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-010,Business Rule,Enforced Correctly,1/1 passed +BR-PMS-011,Business Rule,Partially Enforced,1/2 passed +BR-PMS-013,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-014,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-016,Business Rule,Partially Enforced,1/2 passed +BR-PMS-017,Business Rule,Enforced Correctly,2/2 passed +BR-PMS-018,Business Rule,Partially Enforced,1/2 passed +BR-PMS-019,Business Rule,Enforced Correctly,2/2 passed +PMS-WF-101,Workflow,Complete,2/2 passed +PMS-WF-201,Workflow,Partial,1/2 passed +PMS-WF-301,Workflow,Complete,2/2 passed +PMS-WF-401,Workflow,Complete,2/2 passed +PMS-WF-501,Workflow,Complete,2/2 passed +PMS-WF-601,Workflow,Complete,2/2 passed diff --git a/FusionIIIT/applications/patent_system/tests/reports/BR_Test_Design.csv b/FusionIIIT/applications/patent_system/tests/reports/BR_Test_Design.csv new file mode 100644 index 000000000..8e4c0e829 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/reports/BR_Test_Design.csv @@ -0,0 +1,82 @@ +BR_ID,Title,Category,Input/Action,Expected Result +BR-PMS-001,Patent Application Must Contain All Required Fields,Valid,"Submit application with all fields filled: title, abstract, description, claims, inventor_details, and at least 1 document",validation_passed = TRUE; application proceeds to submission +BR-PMS-001,Patent Application Must Contain All Required Fields,Valid,Submit application with exactly the minimum required document count (1 document),validation_passed = TRUE; application accepted +BR-PMS-001,Patent Application Must Contain All Required Fields,Invalid,Submit application with missing patent_claims field,validation_passed = FALSE; system highlights missing field; submission blocked +BR-PMS-001,Patent Application Must Contain All Required Fields,Invalid,Submit application with no supporting documents uploaded,validation_passed = FALSE; documents.count = 0; error returned +BR-PMS-001,Patent Application Must Contain All Required Fields,Invalid,Submit application with abstract exceeding 500 characters,validation_passed = FALSE; character limit error returned +BR-PMS-002,User Must Have Appropriate Role for Patent Operations,Valid,Faculty member with 'Applicant' role and Active status attempts to submit an application,allow_access = TRUE; operation permitted; access attempt logged +BR-PMS-002,User Must Have Appropriate Role for Patent Operations,Valid,PCC_ADMIN with Active status attempts to assign a Director,allow_access = TRUE; assignment operation permitted +BR-PMS-002,User Must Have Appropriate Role for Patent Operations,Invalid,User without any role assignment attempts to access patent submission form,allow_access = FALSE; authorization error returned; attempt logged +BR-PMS-002,User Must Have Appropriate Role for Patent Operations,Invalid,Student (non-Faculty/Staff) attempts to submit application,allow_access = FALSE; registration_status check fails; blocked +BR-PMS-002,User Must Have Appropriate Role for Patent Operations,Invalid,Inactive registered user attempts to log in and access patent functions,allow_access = FALSE; registration_status = Inactive; access denied +BR-PMS-003,Conflict of Interest Must Be Declared and Prevented,Valid,Director with no inventor relationship reviews assigned application,COI check passes; review allowed to proceed +BR-PMS-003,Conflict of Interest Must Be Declared and Prevented,Valid,Attorney assigned to application with no declared conflict,COI check passes; assignment confirmed +BR-PMS-003,Conflict of Interest Must Be Declared and Prevented,Invalid,Director who is listed as an inventor on the application attempts to review it,Assignment blocked; application reassigned; PCC_ADMIN alerted +BR-PMS-003,Conflict of Interest Must Be Declared and Prevented,Invalid,Attorney with a declared conflict of interest is assigned to an application,Assignment blocked; PCC_ADMIN alerted to select alternate attorney +BR-PMS-004,Application Must Follow Sequential Review Process,Valid,Director submits decision='Approve' within 15 business days of assignment,Attorney assignment enabled; sequential workflow proceeds +BR-PMS-004,Application Must Follow Sequential Review Process,Valid,Director submits decision='Needs Revision' with justification,Decision recorded; applicant notified; revision workflow triggered +BR-PMS-004,Application Must Follow Sequential Review Process,Invalid,Attorney assignment attempted before Director has issued any decision,Assignment blocked; application_status is not 'Approved' +BR-PMS-004,Application Must Follow Sequential Review Process,Invalid,"Director attempts to submit an invalid decision value (e.g., 'Hold')",Decision rejected; only Approve/Reject/Needs Revision are valid +BR-PMS-005,Director Must Provide Justification for Decisions,Valid,Director rejects application with justification_text of 75 characters,Decision accepted; feedback saved; applicant notified +BR-PMS-005,Director Must Provide Justification for Decisions,Valid,Director requests revision with detailed feedback exceeding 50 chars,Revision request accepted; applicant receives detailed feedback +BR-PMS-005,Director Must Provide Justification for Decisions,Invalid,Director rejects application without providing any justification,Decision blocked; justification field is required error returned +BR-PMS-005,Director Must Provide Justification for Decisions,Invalid,Director rejects application with justification_text of only 20 characters,Decision blocked; minimum 50 characters required for justification +BR-PMS-006,Document Access Must Be Controlled Based on Status,Valid,Applicant edits document when application_status = 'Awaiting Revision',Edit allowed; version incremented; version record created +BR-PMS-006,Document Access Must Be Controlled Based on Status,Valid,PCC_ADMIN accesses confidential document with appropriate role permission,Access granted; access event logged +BR-PMS-006,Document Access Must Be Controlled Based on Status,Invalid,Applicant attempts to edit document when application_status = 'Submitted',Edit blocked; document is locked post-submission +BR-PMS-006,Document Access Must Be Controlled Based on Status,Invalid,User without permission for a confidential portfolio_section attempts access,Access denied; authorization error returned +BR-PMS-007,Attorney Assignment Must Follow Institutional Policy,Valid,PCC_ADMIN assigns attorney from approved panel whose specialization matches application domain,Assignment successful; attorney notified +BR-PMS-007,Attorney Assignment Must Follow Institutional Policy,Valid,PCC_ADMIN assigns attorney matching applicant's preferred choice from approved panel,Applicant preference honored; assignment confirmed +BR-PMS-007,Attorney Assignment Must Follow Institutional Policy,Invalid,Director (non-PCC_ADMIN) attempts to directly assign an attorney,Assignment blocked; only PCC_ADMIN has assignment authority +BR-PMS-007,Attorney Assignment Must Follow Institutional Policy,Invalid,PCC_ADMIN attempts to assign attorney not on the approved panel,Assignment blocked; attorney not in approved_panel +BR-PMS-008,Budget Must Be Approved for Patent Applications,Valid,Budget request submitted for Approved application with total_cost within threshold and sufficient budget,Budget approved; financial tracking initiated +BR-PMS-008,Budget Must Be Approved for Patent Applications,Valid,Transaction amount is within PCC_ADMIN authorization limit,Transaction approved without escalation +BR-PMS-008,Budget Must Be Approved for Patent Applications,Invalid,Budget request submitted for application with status = 'Under Review',Budget approval blocked; application must be Approved first +BR-PMS-008,Budget Must Be Approved for Patent Applications,Invalid,Total cost exceeds threshold_limit; budget not escalated,Auto-escalation to Director triggered; PCC_ADMIN cannot approve unilaterally +BR-PMS-008,Budget Must Be Approved for Patent Applications,Invalid,Budget requested when budget_availability < total_cost,Approval blocked; insufficient budget error returned +BR-PMS-009,Post-Grant Tracking Must Be Properly Activated,Valid,Patent status changes to 'Granted'; system activates post-grant tracking,PostGrantRecord created; maintenance schedule calculated; renewal reminders set +BR-PMS-009,Post-Grant Tracking Must Be Properly Activated,Valid,Next fee date calculated as grant_date + defined maintenance_schedule interval,Correct next_fee_date stored; deadline tracker active +BR-PMS-009,Post-Grant Tracking Must Be Properly Activated,Invalid,Post-grant tracking activation attempted for application with status = 'Approved' (not yet Granted),Activation blocked; patent_status must be 'Granted' +BR-PMS-009,Post-Grant Tracking Must Be Properly Activated,Invalid,Payment processed without an explicit renewal decision on record,Payment blocked; explicit renewal decision required first +BR-PMS-010,Document Upload Must Meet Security Requirements,Valid,Upload a 10MB PDF file that passes malware scan,upload_allowed = TRUE; document classified; encrypted and stored +BR-PMS-010,Document Upload Must Meet Security Requirements,Valid,Upload a 52MB (exactly at limit) clean PDF,upload_allowed = TRUE; file accepted at boundary +BR-PMS-010,Document Upload Must Meet Security Requirements,Invalid,Upload a .docx file instead of PDF,upload_denied; file_format mismatch error returned +BR-PMS-010,Document Upload Must Meet Security Requirements,Invalid,"Upload a PDF larger than 50MB (e.g., 60MB)",upload_denied; file size exceeds 50MB limit +BR-PMS-010,Document Upload Must Meet Security Requirements,Invalid,Upload a PDF that fails malware scan,upload_denied; malware detected error returned; security team alerted +BR-PMS-011,System Must Send Automated Notifications,Valid,Application status changes from 'Submitted' to 'Under Review',Notification dispatched to applicant and Director; event logged in audit trail +BR-PMS-011,System Must Send Automated Notifications,Valid,Deadline is 5 days away,Alert generated and sent to responsible party (days_remaining = 5 triggers alert) +BR-PMS-011,System Must Send Automated Notifications,Invalid,Status change event fires but stakeholder email is invalid,Delivery failure logged; PCC_ADMIN alerted to correct contact details +BR-PMS-011,System Must Send Automated Notifications,Invalid,Task assigned time exceeds SLA threshold with no escalation triggered,Escalation fires per escalation_chain; next authority notified +BR-PMS-012,Applications Must Be Assigned Based on Expertise and Hierarchy,Valid,Application submitted; Director A has matching domain and workload below threshold,Application routed to Director A; assignment confirmed; Director notified +BR-PMS-012,Applications Must Be Assigned Based on Expertise and Hierarchy,Valid,Two eligible Directors available; Director B has lower workload,Application assigned to Director B (lowest workload wins) +BR-PMS-012,Applications Must Be Assigned Based on Expertise and Hierarchy,Invalid,All Directors with matching domain expertise are above workload threshold,Assignment fails; PCC_ADMIN alerted; manual assignment required +BR-PMS-012,Applications Must Be Assigned Based on Expertise and Hierarchy,Invalid,Application submitted but no Director has expertise matching application_category,No auto-assignment made; PCC_ADMIN alerted to handle manually +BR-PMS-013,Inventor Agreement Must Be Obtained,Valid,All inventors have signed agreement (physically or via valid e-signature),Submission proceeds; signature_audit stored +BR-PMS-013,Inventor Agreement Must Be Obtained,Valid,Co-inventor signs via e-signature in a jurisdiction where it is legally permitted,e_signature_valid = TRUE; agreement accepted; audit logged +BR-PMS-013,Inventor Agreement Must Be Obtained,Invalid,Application submitted with one co-inventor agreement unsigned,Submission blocked; unsigned agreement error; reminder sent to co-inventor +BR-PMS-013,Inventor Agreement Must Be Obtained,Invalid,E-signature submitted but fails validation checks,Submission blocked; invalid e-signature error; re-signing required +BR-PMS-014,Patentability Assessment Must Follow Legal Framework,Valid,"Attorney submits full assessment with all scores above threshold, valid recommendation, and non-null report",Filing allowed; assessment stored; application progresses +BR-PMS-014,Patentability Assessment Must Follow Legal Framework,Valid,Attorney recommends 'Do Not File' with complete scores and report,Assessment accepted; application flagged accordingly; Director notified +BR-PMS-014,Patentability Assessment Must Follow Legal Framework,Invalid,Filing attempted before attorney has recorded any patentability opinion,Filing blocked; opinion_recorded = FALSE +BR-PMS-014,Patentability Assessment Must Follow Legal Framework,Invalid,Assessment submitted with novelty_score below threshold,Filing blocked; novelty score does not meet legal standard +BR-PMS-014,Patentability Assessment Must Follow Legal Framework,Invalid,Recommendation value submitted as 'Maybe' (not in valid set),Assessment rejected; only 'File Patent' or 'Do Not File' are accepted +BR-PMS-015,Application Priority Must Be Based on Submission Date,Valid,Two standard applications submitted 10 and 5 days ago respectively; no special circumstances,Older application (10 days) receives higher priority_score; processed first +BR-PMS-015,Application Priority Must Be Based on Submission Date,Valid,Application with high strategic_value and urgent urgency_level flagged under special_circumstances,Composite priority_score calculated using weighted formula; application prioritized accordingly +BR-PMS-015,Application Priority Must Be Based on Submission Date,Invalid,Newly submitted application (today) placed ahead of application submitted 30 days ago without special circumstances,Priority calculation corrected; older application has higher priority_score +BR-PMS-016,Application Revision Must Follow Time Limits,Valid,Applicant resubmits on day 45 after rejection with all feedback addressed,allow_resubmission = TRUE; new review cycle triggered +BR-PMS-016,Application Revision Must Follow Time Limits,Valid,Applicant withdraws application while status = 'Needs Revision' (not in non_withdrawable_statuses),Withdrawal allowed with acknowledgment; application locked +BR-PMS-016,Application Revision Must Follow Time Limits,Invalid,Applicant attempts to resubmit on day 65 after rejection,Resubmission blocked; application_status changed to EXPIRED +BR-PMS-016,Application Revision Must Follow Time Limits,Invalid,Applicant resubmits within 60 days but feedback_addressed = FALSE,Resubmission blocked; feedback must be addressed before resubmission +BR-PMS-016,Application Revision Must Follow Time Limits,Invalid,Applicant attempts to withdraw application with status = 'Filed' (in non_withdrawable_statuses),Withdrawal blocked; status is non-withdrawable; PCC_ADMIN authorization required +BR-PMS-017,External Filing Must Follow Patent Office Requirements,Valid,Application formatted per Indian Patent Office requirements; all required fields complete,submission_ready = TRUE; application dispatched to patent office +BR-PMS-017,External Filing Must Follow Patent Office Requirements,Valid,Application filed with a foreign office; justification_text is provided,International filing accepted; justification recorded +BR-PMS-017,External Filing Must Follow Patent Office Requirements,Invalid,Application submitted with missing required fields per patent office schema,format_correction_required; error list returned to attorney +BR-PMS-017,External Filing Must Follow Patent Office Requirements,Invalid,Application filed with non-Indian patent office without justification_text,Filing blocked; justification_text is mandatory for international filings +BR-PMS-018,System Must Maintain Complete Audit Trail,Valid,Applicant submits application; action_type = 'SUBMIT',"Audit record created with user_id, timestamp, previous_state=Draft, new_state=Submitted" +BR-PMS-018,System Must Maintain Complete Audit Trail,Valid,Director issues Rejection decision (critical operation),Immutable audit record created; cannot be modified or deleted +BR-PMS-018,System Must Maintain Complete Audit Trail,Invalid,Status change occurs without triggering audit log creation,Compliance violation detected; missing audit record flagged for investigation +BR-PMS-018,System Must Maintain Complete Audit Trail,Invalid,Attempt to modify an existing immutable audit record,Modification blocked; immutable record cannot be altered +BR-PMS-019,Legal Advice Must Be Properly Documented,Valid,Attorney provides legal opinion on patentability; advice_content is non-null,Documentation record created; confidentiality_level marked; attorney_id attributed; privilege_status = TRUE +BR-PMS-019,Legal Advice Must Be Properly Documented,Valid,Attorney documents legal research with proper attribution and confidentiality,Record stored with all required fields; accessible only to authorized roles +BR-PMS-019,Legal Advice Must Be Properly Documented,Invalid,Legal advice stored without attorney_id attribution,Record rejected; attorney attribution is mandatory +BR-PMS-019,Legal Advice Must Be Properly Documented,Invalid,Legal advice document saved with privilege_status = FALSE,System override sets privilege_status = TRUE; attorney-client privilege enforced diff --git a/FusionIIIT/applications/patent_system/tests/reports/Defect_Log.csv b/FusionIIIT/applications/patent_system/tests/reports/Defect_Log.csv new file mode 100644 index 000000000..fbb4d9302 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/reports/Defect_Log.csv @@ -0,0 +1,119 @@ +Test_ID,Scenario,Outcome,Error Details,Status,Severity +BR-PMS-003-I-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +BR-PMS-011-V-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +BR-PMS-016-I-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +BR-PMS-018-I-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +,,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 633, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_mo",Open,Medium +,,Error,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 633, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_mo",Open,High +PMS-UC-004-AP-01,Applicant resubmits with partial update,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +PMS-UC-004-EX-01,Applicant resubmits after deadline,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +PMS-UC-005-AP-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +PMS-UC-005-HP-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +PMS-UC-006-AP-01,Unhandled exception,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 384, in test_ap01_pcc_updates_existing_attorney_assignment + self.assertEqual(AttorneyAssignment.objects.get(application=app).attorney_name, ""B"") + File ""C:\Users\tan",Open,Medium +PMS-UC-019-AP-01,Search with status filter,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +PMS-UC-019-EX-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +PMS-UC-019-HP-01,Search applications by keyword,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium +PMS-WF-201-NEG-01,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 60, in testPartExecutor + yield + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 676, in run + self._callTestMethod(testMethod) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\Fu",Open,Medium diff --git a/FusionIIIT/applications/patent_system/tests/reports/Module_Test_Summary.csv b/FusionIIIT/applications/patent_system/tests/reports/Module_Test_Summary.csv new file mode 100644 index 000000000..7713c40bf --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/reports/Module_Test_Summary.csv @@ -0,0 +1,18 @@ +Metric,Value +Total Use Cases,20 +Total Business Rules,19 +Total Workflows,6 +Required UC Tests,60 +Designed UC Tests,57 +Required BR Tests,38 +Designed BR Tests,81 +Required WF Tests,12 +Designed WF Tests,16 +UC Adequacy %,95.0% +BR Adequacy %,213.2% +WF Adequacy %,133.3% +Total Tests Executed,103 +Total Pass,88 +Total Partial,0 +Total Fail,15 +Strict Pass Rate %,85.4% diff --git a/FusionIIIT/applications/patent_system/tests/reports/Test_Execution_Log.csv b/FusionIIIT/applications/patent_system/tests/reports/Test_Execution_Log.csv new file mode 100644 index 000000000..5bc0b371d --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/reports/Test_Execution_Log.csv @@ -0,0 +1,243 @@ +Test_ID,Scenario,Input/Action,Expected Result,Actual Result,Status,Evidence,Tester,Timestamp +BR-PMS-001-I-01,Submit application missing required field,,400; Missing required field,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-001-V-01,Submit application with complete payload,POST /patentsystem/applicant/applications/submit/,201; Application created,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-002-I-01,Unauthenticated user submits,,401,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-002-V-01,Authenticated applicant submits,,201,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-003-I-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_business_rules.py"", line 148, in test_invalid_director_inventor_conflict_blocked + self._consent_all(app) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_business_rules.py"", line 37, in _consent_all + self.api_post(self.API_PREFIX + f""applicant/applications/{app.id}/consent/"", {}, expected_status=200) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 310, in api_post + self.assertEqual(resp.status_code, expected_status, msg=getattr(resp, ""content"", b"""")[:500]) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 912, in assertEqual + assertion_func(first, second, msg=msg) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 905, in _baseAssertEqual + raise self.failureException(msg) +AssertionError: 400 != 200 : b'{""error"": ""User is not a registered applicant.""}' +",Tester,2026-04-07 16:48:44 +BR-PMS-003-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-004-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-004-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-005-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-005-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-006-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-006-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-007-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-007-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-008-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-008-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-009-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-009-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-010-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-011-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-011-V-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_business_rules.py"", line 328, in test_valid_unread_count_increments_after_submit + self.assertGreaterEqual(r.json().get(""unread_count"", 0), 1) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 1316, in assertGreaterEqual + self.fail(self._formatMessage(msg, standardMsg)) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 753, in fail + raise self.failureException(msg) +AssertionError: 0 not greater than or equal to 1 +",Tester,2026-04-07 16:48:44 +BR-PMS-013-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-013-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-014-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-014-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-016-I-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_business_rules.py"", line 452, in test_invalid_resubmit_after_deadline_expires + self.assertEqual(app.status, ApplicationStatus.EXPIRED) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 912, in assertEqual + assertion_func(first, second, msg=msg) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 905, in _baseAssertEqual + raise self.failureException(msg) +AssertionError: 'Needs Revision' != +",Tester,2026-04-07 16:48:44 +BR-PMS-016-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-017-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-017-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-018-I-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_business_rules.py"", line 515, in test_invalid_non_pcc_cannot_view_audit_logs + self.api_get(url, expected_status=403) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 303, in api_get + self.assertEqual(resp.status_code, expected_status, msg=getattr(resp, ""content"", b"""")[:500]) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 912, in assertEqual + assertion_func(first, second, msg=msg) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 905, in _baseAssertEqual + raise self.failureException(msg) +AssertionError: 200 != 403 : b'[{""id"":174,""application"":158,""user"":11009,""user_name"":"""",""action"":""Auto-transitioned to Submitted - All Consents Received"",""previous_state"":""Pending Inventor Consent"",""new_state"":""Submitted"",""details"":"""",""timestamp"":""2026-04-07T16:47:44.137937""},{""id"":173,""application"":158,""user"":11009,""user_name"":"""",""action"":""Inventor consent given by inventor2"",""previous_state"":"""",""new_state"":"""",""details"":"""",""timestamp"":""2026-04-07T16:47:44.133936""},{""id"":172,""application"":158,""user"":11008,""user_name"":"""",""acti' +",Tester,2026-04-07 16:48:44 +BR-PMS-018-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-019-I-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +BR-PMS-019-V-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Fail,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Pass,,Tester,2026-04-07 16:48:44 +,,,,,Error,,Tester,2026-04-07 16:48:44 +PMS-UC-001-AP-01,All inventors give consent; system auto-submits,POST /.../consent/ by each inventor,status transitions to Submitted,status=Submitted,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-001-EX-01,Unauthenticated user attempts submission,POST /patentsystem/applicant/applications/submit/,401 Unauthorized,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-001-HP-01,Applicant submits complete application,POST /patentsystem/applicant/applications/submit/,201; Application created with status=Pending Inventor Consent,"Created application_id=171, status=Pending Inventor Consent",Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-002-AP-01,Forwarding with comment >1000 chars,,400 Validation error,status=400,Pass,"{""error"": ""Comments must be \u2264 1000 characters.""}",Tester,2026-04-07 16:48:44 +PMS-UC-002-EX-01,Non-PCC user tries to forward,,403 Forbidden,status=403,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-002-HP-01,PCC Admin forwards reviewed application to Director,POST /patentsystem/pccAdmin/applications/new/forward/{id}/,200; status becomes Forwarded for Director's Review,status=Forwarded for Director's Review,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-003-AP-01,Director requests revision,,200; status=Needs Revision,status=Needs Revision,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-003-EX-01,Director rejects with too-short feedback,,400; validation error,status=400,Pass,"{""error"": ""Feedback must be at least 50 characters for Reject / Needs Revision.""}",Tester,2026-04-07 16:48:44 +PMS-UC-003-HP-01,Director approves,,200; status=Approved,status=Approved,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-004-AP-01,Applicant resubmits with partial update,,200; title updated; status=Resubmitted,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 290, in test_ap01_resubmit_updates_title_only + self.assertEqual(r.status_code, 200) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 912, in assertEqual + assertion_func(first, second, msg=msg) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 905, in _baseAssertEqual + raise self.failureException(msg) +AssertionError: 400 != 200 +",Tester,2026-04-07 16:48:44 +PMS-UC-004-EX-01,Applicant resubmits after deadline,,400; status=Expired,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 310, in test_ex01_resubmit_after_deadline_marks_expired + self.assertEqual(app.status, ApplicationStatus.EXPIRED) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 912, in assertEqual + assertion_func(first, second, msg=msg) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 905, in _baseAssertEqual + raise self.failureException(msg) +AssertionError: 'Needs Revision' != +",Tester,2026-04-07 16:48:44 +PMS-UC-004-HP-01,Applicant resubmits within deadline,,200; status=Resubmitted,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-005-AP-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 335, in test_ap01_applicant_views_details_of_own_application + self.assertEqual(str(r.json().get(""id"")), str(app.id)) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 912, in assertEqual + assertion_func(first, second, msg=msg) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 1292, in assertMultiLineEqual + self.fail(self._formatMessage(msg, standardMsg)) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 753, in fail + raise self.failureException(msg) +AssertionError: 'None' != '181' +- None ++ 181 + +",Tester,2026-04-07 16:48:44 +PMS-UC-005-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-005-HP-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 326, in test_hp01_applicant_views_own_applications_list + self.assertTrue(any(str(a.get(""id"")) == str(app.id) for a in data)) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 765, in assertTrue + raise self.failureException(msg) +AssertionError: False is not true +",Tester,2026-04-07 16:48:44 +PMS-UC-006-AP-01,Unhandled exception,,,AttorneyAssignment matching query does not exist.,Error,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 384, in test_ap01_pcc_updates_existing_attorney_assignment + self.assertEqual(AttorneyAssignment.objects.get(application=app).attorney_name, ""B"") + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\venv\lib\site-packages\django\db\models\manager.py"", line 85, in manager_method + return getattr(self.get_queryset(), name)(*args, **kwargs) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\venv\lib\site-packages\django\db\models\query.py"", line 429, in get + raise self.model.DoesNotExist( +applications.patent_system.models.AttorneyAssignment.DoesNotExist: AttorneyAssignment matching query does not exist. +",Tester,2026-04-07 16:48:44 +PMS-UC-006-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-006-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-007-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-007-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-007-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-008-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-008-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-008-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-009-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-009-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-009-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-010-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-010-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-010-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-014-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-014-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-014-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-015-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-015-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-015-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-016-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-016-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-016-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-018-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-018-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-018-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-019-AP-01,Search with status filter,,200; filtered result set,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 835, in test_ap01_search_filters_by_status + self.assertIn(""items"", body) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 1179, in assertIn + self.fail(self._formatMessage(msg, standardMsg)) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 753, in fail + raise self.failureException(msg) +AssertionError: 'items' not found in [] +",Tester,2026-04-07 16:48:44 +PMS-UC-019-EX-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 843, in test_ex01_search_rejects_invalid_date_format + self.assertIn(""items"", r.json()) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 1179, in assertIn + self.fail(self._formatMessage(msg, standardMsg)) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 753, in fail + raise self.failureException(msg) +AssertionError: 'items' not found in [] +",Tester,2026-04-07 16:48:44 +PMS-UC-019-HP-01,Search applications by keyword,,200; returns results with total + items,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_use_cases.py"", line 822, in test_hp01_search_by_query_returns_results_structure + self.assertIn(""items"", body) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 1179, in assertIn + self.fail(self._formatMessage(msg, standardMsg)) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 753, in fail + raise self.failureException(msg) +AssertionError: 'items' not found in [] +",Tester,2026-04-07 16:48:44 +PMS-UC-020-AP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-020-EX-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-UC-020-HP-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-101-E2E-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-101-NEG-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-201-E2E-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-201-NEG-01,Assertion failed,,,Assertion failed,Fail,"Traceback (most recent call last): + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\base.py"", line 202, in _callTestMethod + method() + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\Fusioncode\Fusion\FusionIIIT\applications\patent_system\tests\test_workflows.py"", line 124, in test_negative_expired + self.assertEqual(app.status, ApplicationStatus.EXPIRED) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 912, in assertEqual + assertion_func(first, second, msg=msg) + File ""C:\Users\tanay\OneDrive\Desktop\Fusion1\lib\unittest\case.py"", line 905, in _baseAssertEqual + raise self.failureException(msg) +AssertionError: 'Needs Revision' != +",Tester,2026-04-07 16:48:44 +PMS-WF-301-E2E-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-301-NEG-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-401-E2E-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-401-NEG-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-501-E2E-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-501-NEG-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-601-E2E-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 +PMS-WF-601-NEG-01,Completed,,,OK,Pass,,Tester,2026-04-07 16:48:44 diff --git a/FusionIIIT/applications/patent_system/tests/reports/UC_Test_Design.csv b/FusionIIIT/applications/patent_system/tests/reports/UC_Test_Design.csv new file mode 100644 index 000000000..8d054f6d1 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/reports/UC_Test_Design.csv @@ -0,0 +1,58 @@ +UC_ID,Title,Category,Scenario,Preconditions,Input/Action,Expected Result +PMS-UC-001,Submit Patent Application,Happy Path,Applicant submits a complete application with valid documents,Applicant is logged in and authorized,"POST /patent/application with title, abstract, description, claims, inventors, uploaded_documents",PatentApplication created with status=Submitted; confirmation email sent; appears on applicant dashboard +PMS-UC-001,Submit Patent Application,Alternate Path,Applicant saves an incomplete application as draft,Applicant is logged in,"POST /patent/application with partial fields, action=save_draft",Application saved as draft; no notification sent to PCC_ADMIN +PMS-UC-001,Submit Patent Application,Exception,Unauthorized user attempts to submit,User is logged in without 'Applicant' role,POST /patent/application,Request rejected with authorization error; submission blocked +PMS-UC-001,Submit Patent Application,Exception,Applicant submits incomplete application,Applicant is logged in,POST /patent/application with missing required fields,System highlights missing fields; application not submitted; returns to form +PMS-UC-001,Submit Patent Application,Exception,Document validation fails during upload,Applicant is logged in,POST /patent/application/documents with invalid file format,Error displayed for invalid document; retry upload prompted +PMS-UC-002,Assign Application to Director,Happy Path,System auto-assigns application to Director based on expertise match,Application status is Submitted; eligible Directors are available,PATCH /patent/application/{id}/assign with director_selection=auto,Application status updated to Under Review; Director notified; review timeline initiated +PMS-UC-002,Assign Application to Director,Alternate Path,PCC_ADMIN manually overrides auto-assignment,Application is Submitted; PCC_ADMIN is logged in,PATCH /patent/application/{id}/assign with director_id={manual_id},Application assigned to selected Director; status updated; Director notified +PMS-UC-002,Assign Application to Director,Exception,No eligible Director is available,Application is Submitted; no Director with matching expertise has capacity,PATCH /patent/application/{id}/assign with director_selection=auto,Assignment fails; retry scheduled; application remains Submitted +PMS-UC-002,Assign Application to Director,Exception,Assignment conflict detected,Director already at max workload,PATCH /patent/application/{id}/assign with director_id={overloaded_id},Conflict flagged; manual override required; assignment not saved +PMS-UC-003,Review Patent Application,Happy Path,Director approves a complete and valid application,Application is Under Review and assigned to Director,"POST /patent/application/{id}/review with decision=Approved, feedback={comments}",Application status updated to Approved; applicant notified; audit log updated +PMS-UC-003,Review Patent Application,Alternate Path,Director requests revision from applicant,Application is Under Review,"POST /patent/application/{id}/review with decision=Needs Revision, feedback={detailed_comments}",Application status changed to Needs Revision; applicant notified with feedback +PMS-UC-003,Review Patent Application,Alternate Path,Director defers the review,Application is Under Review,"POST /patent/application/{id}/review with action=defer, note={reason}",Deferral note added; timeline extended; application remains Under Review +PMS-UC-003,Review Patent Application,Exception,Review timeline is violated,Application has exceeded review deadline,GET /patent/application/{id}/review_status,Escalation logged; timeline violation recorded; PCC_ADMIN notified +PMS-UC-004,Revise and Resubmit Application,Happy Path,Applicant addresses all feedback and resubmits within 60 days,Application is in Needs Revision status; within 60-day resubmission window,"PUT /patent/application/{id}/revise with updated_fields, updated_documents",Application status changed to Resubmitted; new review cycle triggered; stakeholders notified +PMS-UC-004,Revise and Resubmit Application,Alternate Path,Applicant partially updates and saves before final resubmission,Application is in Needs Revision; within allowed window,PUT /patent/application/{id}/revise with action=save_draft,Draft revision saved; version history maintained; no status change +PMS-UC-004,Revise and Resubmit Application,Exception,Applicant attempts to resubmit after the 60-day window has expired,Application is in Needs Revision; 60-day deadline has passed,PUT /patent/application/{id}/revise,Submission rejected with date/deadline error; status changed to Expired; applicant notified +PMS-UC-004,Revise and Resubmit Application,Exception,Revised application does not address the feedback,Application is in Needs Revision,PUT /patent/application/{id}/revise with unaddressed feedback items,System rejects revision; applicant prompted to make further edits +PMS-UC-005,Track Application Status,Happy Path,Applicant views current status of their application,Applicant is logged in; application exists in the system,GET /patent/application/{id}/status,"Current status, history timeline, and pending actions displayed" +PMS-UC-005,Track Application Status,Alternate Path,PCC_ADMIN views status of all applications in a batch,PCC_ADMIN is logged in,GET /patent/applications?filter=all&sort=status,Filtered and sorted list of applications with statuses displayed +PMS-UC-005,Track Application Status,Exception,Applicant attempts to view another applicant's application,Applicant is logged in,GET /patent/application/{other_id}/status,Access denied with authorization error +PMS-UC-006,Assign Attorney,Happy Path,PCC_ADMIN assigns available attorney to application,Application requires legal review; attorney is available,PATCH /patent/application/{id}/assign_attorney with attorney_id={id},Attorney assigned; application updated; attorney notified +PMS-UC-006,Assign Attorney,Exception,No attorney is available for assignment,All attorneys are at capacity,PATCH /patent/application/{id}/assign_attorney with attorney_selection=auto,Assignment fails; PCC_ADMIN alerted to manually resolve +PMS-UC-007,Assess Application Patentability (Legal Assessment),Happy Path,Attorney completes legal assessment and marks application as patentable,Attorney is assigned and application is accessible,"POST /patent/application/{id}/legal_assessment with result=Patentable, notes={details}",Legal assessment recorded; application progresses to filing stage +PMS-UC-007,Assess Application Patentability (Legal Assessment),Exception,Attorney finds significant legal conflict; application is not patentable,Attorney is assigned,"POST /patent/application/{id}/legal_assessment with result=Not Patentable, notes={reason}",Application flagged; Director and applicant notified; further action required +PMS-UC-008,Manage Budgets and Financial Approval,Happy Path,PCC_ADMIN approves budget within threshold,Budget request submitted; amount is within PCC_ADMIN approval limit,"POST /patent/application/{id}/budget_approval with amount={value}, approved_by=PCC_ADMIN",Budget approved; financial tracking initiated; stakeholders notified +PMS-UC-008,Manage Budgets and Financial Approval,Alternate Path,Budget exceeds PCC_ADMIN threshold; escalated to Director,Budget request submitted; amount exceeds PCC_ADMIN authority,POST /patent/application/{id}/budget_approval with amount={high_value},Request escalated to Director for approval +PMS-UC-008,Manage Budgets and Financial Approval,Exception,Budget request denied by Director,Escalated budget request is under Director review,POST /patent/application/{id}/budget_approval with decision=Denied,Budget denied; applicant and PCC_ADMIN notified; application on hold +PMS-UC-009,File with Patent Office / Log Official Filing,Happy Path,Attorney files application with patent office and logs confirmation,All approvals in place,"POST /patent/application/{id}/official_filing with filing_date, office_reference_number",Filing logged; application status updated to Filed; deadline tracking initiated +PMS-UC-009,File with Patent Office / Log Official Filing,Exception,Patent office rejects filing due to format error,Application submitted to patent office,POST /patent/application/{id}/official_filing with malformed documents,Filing error logged; attorney notified to correct and refile +PMS-UC-010,Track Application Progress and Notifications,Happy Path,System sends status-change notification to applicant automatically,Application status changes to a new stage,SYSTEM_EVENT: application.status_changed with new_status={value},Notification dispatched to applicant and relevant parties; event logged in audit trail +PMS-UC-010,Track Application Progress and Notifications,Exception,Notification delivery fails due to invalid email,Status change event triggered,SYSTEM_EVENT: application.status_changed with invalid email on record,Delivery failure logged; PCC_ADMIN alerted to update contact details +PMS-UC-011,Respond to Office Actions / Amend and Resubmit,Happy Path,Attorney submits timely response to Office Action,Office Action received; within response deadline,"POST /patent/application/{id}/office_action_response with response_document, amendments",Response filed; application status updated; deadline extended or closed +PMS-UC-011,Respond to Office Actions / Amend and Resubmit,Exception,Response deadline passes without submission,Office Action response deadline exceeded,SYSTEM_EVENT: office_action.deadline_passed,Application flagged as at-risk; escalation notification sent to PCC_ADMIN and Director +PMS-UC-012,Track and Manage Deadlines,Happy Path,System sends deadline reminder 7 days before due date,Deadline is approaching,SYSTEM_EVENT: deadline.approaching with days_remaining=7,Reminder notification sent to responsible party; logged in audit trail +PMS-UC-012,Track and Manage Deadlines,Exception,Deadline is missed; escalation triggered,Deadline has passed without action,SYSTEM_EVENT: deadline.missed,Escalation created; PCC_ADMIN and Director notified; application flagged +PMS-UC-013,Manage Maintenance Fees and Renewals,Happy Path,PCC_ADMIN processes maintenance fee payment on time,Maintenance fee is due; budget available,"POST /patent/{id}/maintenance_fee with payment_amount, payment_date",Payment recorded; patent renewal confirmed; next deadline scheduled +PMS-UC-013,Manage Maintenance Fees and Renewals,Exception,Maintenance fee not paid before deadline,Fee due date has passed,SYSTEM_EVENT: maintenance_fee.overdue,Patent flagged for lapse; escalation notification sent; grace period window activated +PMS-UC-014,Withdraw Patent Application,Happy Path,Applicant withdraws application before review begins,Application status is Submitted or Draft,DELETE /patent/application/{id} with reason={withdrawal_reason},Application status changed to Withdrawn; record locked; stakeholders notified +PMS-UC-014,Withdraw Patent Application,Exception,Applicant attempts to withdraw an already-approved application,Application status is Approved or Filed,DELETE /patent/application/{id},Withdrawal rejected with status error; PCC_ADMIN must authorize any late-stage withdrawal +PMS-UC-015,Generate Reports and Analytics Dashboards,Happy Path,PCC_ADMIN generates a monthly application status report,PCC_ADMIN is logged in,GET /reports/applications?period=monthly&format=pdf,PDF report generated with application counts by status; download link provided +PMS-UC-015,Generate Reports and Analytics Dashboards,Alternate Path,Director views live analytics dashboard,Director is logged in,GET /dashboard/director,"Interactive dashboard displayed with current pipeline, pending reviews, and deadlines" +PMS-UC-015,Generate Reports and Analytics Dashboards,Exception,Report generation fails due to data query timeout,Large dataset being queried,GET /reports/applications?period=annual,Timeout error returned; user advised to narrow filter or retry +PMS-UC-016,Manage Co-Inventors and Inventor Agreements,Happy Path,Applicant adds co-inventor and system sends agreement for e-signature,Application is in Draft; co-inventor email is valid,"POST /patent/application/{id}/inventors with co_inventor_email, contribution_percentage",Co-inventor added; agreement email dispatched; application cannot be submitted until agreement is signed +PMS-UC-016,Manage Co-Inventors and Inventor Agreements,Exception,Co-inventor does not sign agreement before submission deadline,Application ready for submission; co-inventor agreement pending,POST /patent/application/{id}/submit,Submission blocked with unsigned agreement error; reminder sent to co-inventor +PMS-UC-017,Patent Licensing / Tech Transfer Request,Happy Path,Applicant submits licensing request; committee approves and agreement is finalized,Patent is Granted; licensing policy exists,"POST /patent/{id}/licensing_request with proposed_terms, external_party_details",License record created; IP and financial systems updated; stakeholders notified +PMS-UC-017,Patent Licensing / Tech Transfer Request,Alternate Path,Negotiation involves confidential terms; redacted record created,Licensing negotiation in progress,POST /patent/{id}/licensing_request with confidential=true,Redacted record stored; accessible only to authorized roles +PMS-UC-017,Patent Licensing / Tech Transfer Request,Exception,Negotiation fails; request archived,Licensing request submitted; negotiation rounds exhausted,PATCH /patent/{id}/licensing_request/{req_id} with status=Failed,Request archived; retry option available; no financial updates made +PMS-UC-018,Appeal Against Rejection,Happy Path,Applicant files appeal within time limit; Director upholds and remands for amendment,Application is Rejected; appeal window is open,"POST /patent/application/{id}/appeal with grounds={justification}, supporting_documents",Appeal logged; Director reviews; decision to remand routes application to attorney +PMS-UC-018,Appeal Against Rejection,Alternate Path,Director modifies the original decision upon appeal,Appeal is under review by Director,POST /patent/application/{id}/appeal/{appeal_id}/decision with outcome=Modified,Application status updated per modified decision; applicant notified +PMS-UC-018,Appeal Against Rejection,Exception,Appeal submitted after the appeal period has expired,Rejection is recorded; appeal window has closed,POST /patent/application/{id}/appeal,Appeal rejected with time-expiry error; process closed +PMS-UC-019,Search Prior Art,Happy Path,Applicant searches prior art and attaches relevant references to the application,Application is in Draft or Under Review; database is accessible,GET /prior_art/search?keywords={terms}&classifications={cls}&date_range={range},Ranked search results returned; selected references attached to application; session logged +PMS-UC-019,Search Prior Art,Alternate Path,No results found; system suggests keyword refinements,Search performed with narrow or niche keywords,GET /prior_art/search?keywords={narrow_terms},Empty results with keyword expansion suggestions displayed; user can refine and retry +PMS-UC-019,Search Prior Art,Exception,Database query fails due to external service unavailability,Search initiated; external patent database is down,GET /prior_art/search?keywords={terms},Error message displayed; session ends gracefully; retry option shown +PMS-UC-020,Manage Document Versions,Happy Path,Applicant uploads a new version of the patent abstract; history maintained,Application is active; applicant has edit access,POST /patent/application/{id}/documents/{doc_id}/versions with new_file,New version saved; previous version archived; version history updated; stakeholders notified +PMS-UC-020,Manage Document Versions,Alternate Path,User views version history and compares two versions,At least two document versions exist,"GET /patent/application/{id}/documents/{doc_id}/versions?compare=v1,v2",Version comparison view displayed with tracked changes highlighted +PMS-UC-020,Manage Document Versions,Exception,Uploaded document fails virus scan,New version upload initiated,POST /patent/application/{id}/documents/{doc_id}/versions with infected_file,Upload blocked; virus scan failure error shown; previous version remains current; notification sent +PMS-UC-020,Manage Document Versions,Exception,Version conflict detected between simultaneous uploads,Two users upload a new version at the same time,POST /patent/application/{id}/documents/{doc_id}/versions (concurrent requests),Conflict detected; merge process initiated; neither version automatically published until resolved diff --git a/FusionIIIT/applications/patent_system/tests/reports/WF_Test_Design.csv b/FusionIIIT/applications/patent_system/tests/reports/WF_Test_Design.csv new file mode 100644 index 000000000..0e73bd670 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/reports/WF_Test_Design.csv @@ -0,0 +1,17 @@ +WF_ID,Title,Category,Scenario,Expected Final State +PMS-WF-101,Submit Patent Application,End-to-End,Authorized applicant submits a complete application end-to-end,Application status=Submitted; confirmation email sent; PCC_ADMIN notified +PMS-WF-101,Submit Patent Application,Negative,Unauthorized user attempts to access the application form,END-VALIDATION_FAILED; access blocked with authorization error +PMS-WF-101,Submit Patent Application,Negative,Applicant withdraws the draft before submission,END-DRAFT_WITHDRAWN; application deleted; no notifications sent +PMS-WF-201,Application Revision and Resubmission,End-to-End,Applicant revises and resubmits within the 60-day window,END-RESUBMITTED; application enters new review cycle; version history maintained +PMS-WF-201,Application Revision and Resubmission,Negative,Applicant misses the 60-day resubmission deadline,END-EXPIRED; status changed to Expired; applicant notified +PMS-WF-201,Application Revision and Resubmission,Negative,Applicant withdraws application during the revision phase,END-WITHDRAWN; application locked and archived +PMS-WF-301,Budget Approval and Financial Tracking,End-to-End,Budget within PCC_ADMIN threshold is approved and financial tracking initiated,END-BUDGET_APPROVED; financial record updated; stakeholders notified +PMS-WF-301,Budget Approval and Financial Tracking,Negative,Budget exceeds PCC_ADMIN authority; escalated to Director who denies it,END-APPROVAL_DENIED; application on financial hold; PCC_ADMIN notified +PMS-WF-401,Director Assignment and Routing,End-to-End,System auto-assigns application to best-matched Director and initiates review,END-ASSIGNED; application status=Under Review; Director receives notification +PMS-WF-401,Director Assignment and Routing,Negative,No Director is available; assignment fails and retry is scheduled,END-ASSIGNMENT_FAILED; application remains Submitted; retry scheduled; PCC_ADMIN alerted +PMS-WF-501,Post-Grant Tracking and Maintenance,End-to-End,PCC_ADMIN pays maintenance fee on time; patent renewed and next deadline scheduled,END-FEE_PAID; patent status remains Granted; next deadline set +PMS-WF-501,Post-Grant Tracking and Maintenance,Negative,Maintenance fee missed; patent enters lapse risk with grace period activated,END-GRACE_PERIOD_ACTIVE; patent flagged for lapse risk; 30-day grace window active +PMS-WF-601,External Filing and Office Communication,End-to-End,Attorney files application and receives confirmation; deadline tracking begins,END-FILED; application status=Filed; filing date recorded; deadlines active +PMS-WF-601,External Filing and Office Communication,End-to-End,Office Action received; attorney responds within deadline; action closed,END-OFFICE_ACTION_RESOLVED; response filed; application progresses +PMS-WF-601,External Filing and Office Communication,Negative,Filing rejected by patent office due to document error,END-FILING_FAILED; error logged; attorney notified to correct and refile +PMS-WF-601,External Filing and Office Communication,Negative,Attorney fails to respond to Office Action before deadline,Application flagged at-risk; escalation sent to PCC_ADMIN and Director diff --git a/FusionIIIT/applications/patent_system/tests/runner.py b/FusionIIIT/applications/patent_system/tests/runner.py new file mode 100644 index 000000000..5865251cd --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/runner.py @@ -0,0 +1,206 @@ +""" +runner.py — Custom Django test runner + CSV report generator. +Generates all 7 required CSV deliverable sheets. +""" + +import csv +import os +import traceback +from datetime import datetime +from unittest import TestResult + +import yaml +from django.test.runner import DiscoverRunner + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SPECS_DIR = os.path.join(_THIS_DIR, 'specs') +_REPORTS_DIR = os.path.join(_THIS_DIR, 'reports') + +def _ensure_reports_dir(): + os.makedirs(_REPORTS_DIR, exist_ok=True) + +def _load_yaml(filename): + path = os.path.join(_SPECS_DIR, filename) + if not os.path.exists(path): + return {} + with open(path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) or {} + +class ReportingTestResult(TestResult): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.test_records = [] + self.tester_name = os.environ.get('TESTER_NAME', 'Tester') + + def _extract_metadata(self, test): + return { + 'test_id': getattr(test, '_test_id', '') or '', + 'uc_id': getattr(test, '_uc_id', '') or '', + 'br_id': getattr(test, '_br_id', '') or '', + 'wf_id': getattr(test, '_wf_id', '') or '', + 'test_category': getattr(test, '_test_category', '') or '', + 'scenario': getattr(test, '_scenario', '') or '', + 'preconditions': getattr(test, '_preconditions', '') or '', + 'input_action': getattr(test, '_input_action', '') or '', + 'expected_result': getattr(test, '_expected_result', '') or '', + 'results': list(getattr(test, '_results', [])), + 'steps': list(getattr(test, '_steps', [])), + } + + def addSuccess(self, test): + super().addSuccess(test) + meta = self._extract_metadata(test) + meta['outcome'] = 'Pass' + meta['error'] = '' + self.test_records.append(meta) + + def addFailure(self, test, err): + super().addFailure(test, err) + meta = self._extract_metadata(test) + meta['outcome'] = 'Fail' + meta['error'] = ''.join(traceback.format_exception(*err)) + self.test_records.append(meta) + + def addError(self, test, err): + super().addError(test, err) + meta = self._extract_metadata(test) + meta['outcome'] = 'Error' + meta['error'] = ''.join(traceback.format_exception(*err)) + self.test_records.append(meta) + +def _write_csv(filename, headers, rows): + path = os.path.join(_REPORTS_DIR, filename) + with open(path, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerows(rows) + +def generate_uc_test_design(): + specs = _load_yaml('use_cases.yaml') + rows = [] + for uc in specs.get('use_cases', []): + uc_id = uc.get('id', '') + title = uc.get('title', '') + for hp in uc.get('happy_paths', []): + rows.append([uc_id, title, 'Happy Path', hp.get('scenario', ''), hp.get('preconditions', ''), hp.get('input_action', ''), hp.get('expected_result', '')]) + for ap in uc.get('alternate_paths', []): + rows.append([uc_id, title, 'Alternate Path', ap.get('scenario', ''), ap.get('preconditions', ''), ap.get('input_action', ''), ap.get('expected_result', '')]) + for ex in uc.get('exception_paths', []): + rows.append([uc_id, title, 'Exception', ex.get('scenario', ''), ex.get('preconditions', ''), ex.get('input_action', ''), ex.get('expected_result', '')]) + _write_csv('UC_Test_Design.csv', ['UC_ID', 'Title', 'Category', 'Scenario', 'Preconditions', 'Input/Action', 'Expected Result'], rows) + return rows + +def generate_br_test_design(): + specs = _load_yaml('business_rules.yaml') + rows = [] + for br in specs.get('business_rules', []): + br_id = br.get('id', '') + title = br.get('title', '') + for vt in br.get('valid_tests', []): + rows.append([br_id, title, 'Valid', vt.get('input_action', ''), vt.get('expected_result', '')]) + for it in br.get('invalid_tests', []): + rows.append([br_id, title, 'Invalid', it.get('input_action', ''), it.get('expected_result', '')]) + _write_csv('BR_Test_Design.csv', ['BR_ID', 'Title', 'Category', 'Input/Action', 'Expected Result'], rows) + return rows + +def generate_wf_test_design(): + specs = _load_yaml('workflows.yaml') + rows = [] + for wf in specs.get('workflows', []): + wf_id = wf.get('id', '') + title = wf.get('title', '') + for e2e in wf.get('e2e_tests', []): + rows.append([wf_id, title, 'End-to-End', e2e.get('scenario', ''), e2e.get('expected_final_state', '')]) + for neg in wf.get('negative_tests', []): + rows.append([wf_id, title, 'Negative', neg.get('scenario', ''), neg.get('expected_final_state', '')]) + _write_csv('WF_Test_Design.csv', ['WF_ID', 'Title', 'Category', 'Scenario', 'Expected Final State'], rows) + return rows + +def generate_execution_log(records, tester_name): + rows = [] + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + for rec in records: + test_id = rec.get('test_id', 'N/A') + outcome = rec.get('outcome', 'N/A') + results = rec.get('results', []) + actual = results[0].get('actual', '') if results else '' + evidence = results[0].get('evidence', '') if results else '' + status_from_results = results[0].get('status', outcome) if results else outcome + rows.append([test_id, rec.get('scenario', ''), rec.get('input_action', ''), rec.get('expected_result', ''), actual, status_from_results, evidence, tester_name, timestamp]) + _write_csv('Test_Execution_Log.csv', ['Test_ID', 'Scenario', 'Input/Action', 'Expected Result', 'Actual Result', 'Status', 'Evidence', 'Tester', 'Timestamp'], rows) + return rows + +def generate_defect_log(records): + rows = [] + for rec in records: + if rec.get('outcome') in ('Fail', 'Error'): + rows.append([rec.get('test_id', 'N/A'), rec.get('scenario', ''), rec.get('outcome', ''), str(rec.get('error', ''))[:500], 'Open', 'High' if rec.get('outcome') == 'Error' else 'Medium']) + _write_csv('Defect_Log.csv', ['Test_ID', 'Scenario', 'Outcome', 'Error Details', 'Status', 'Severity'], rows) + return rows + +def _evaluate_status(items, pass_key, spec_type): + if not items: + return 'Not Implemented' if spec_type == 'UC' else 'Not Enforced' if spec_type == 'BR' else 'Missing' + total = len(items) + passed = sum(1 for i in items if i == 'Pass') + failed = sum(1 for i in items if i in ('Fail', 'Error')) + if passed == total: + return 'Implemented Correctly' if spec_type == 'UC' else 'Enforced Correctly' if spec_type == 'BR' else 'Complete' + elif passed > 0: + return 'Partially Implemented' if spec_type == 'UC' else 'Partially Enforced' if spec_type == 'BR' else 'Partial' + elif failed == total: + return 'Incorrectly Implemented' if spec_type == 'UC' else 'Incorrectly Enforced' if spec_type == 'BR' else 'Incorrect' + return 'Not Implemented' if spec_type == 'UC' else 'Not Enforced' if spec_type == 'BR' else 'Missing' + +def generate_artifact_evaluation(records): + uc_outcomes, br_outcomes, wf_outcomes = {}, {}, {} + for rec in records: + out = rec.get('outcome', 'N/A') + if rec.get('uc_id'): uc_outcomes.setdefault(rec.get('uc_id'), []).append(out) + if rec.get('br_id'): br_outcomes.setdefault(rec.get('br_id'), []).append(out) + if rec.get('wf_id'): wf_outcomes.setdefault(rec.get('wf_id'), []).append(out) + rows = [] + for uid, outs in sorted(uc_outcomes.items()): rows.append([uid, 'Use Case', _evaluate_status(outs, 'Pass', 'UC'), f"{outs.count('Pass')}/{len(outs)} passed"]) + for bid, outs in sorted(br_outcomes.items()): rows.append([bid, 'Business Rule', _evaluate_status(outs, 'Pass', 'BR'), f"{outs.count('Pass')}/{len(outs)} passed"]) + for wid, outs in sorted(wf_outcomes.items()): rows.append([wid, 'Workflow', _evaluate_status(outs, 'Pass', 'WF'), f"{outs.count('Pass')}/{len(outs)} passed"]) + _write_csv('Artifact_Evaluation.csv', ['Artifact_ID', 'Type', 'Status', 'Details'], rows) + return rows + +def generate_module_test_summary(records, uc_designs, br_designs, wf_designs): + specs_uc, specs_br, specs_wf = _load_yaml('use_cases.yaml'), _load_yaml('business_rules.yaml'), _load_yaml('workflows.yaml') + num_ucs, num_brs, num_wfs = len(specs_uc.get('use_cases', [])), len(specs_br.get('business_rules', [])), len(specs_wf.get('workflows', [])) + req_uc, req_br, req_wf = 3 * num_ucs, 2 * num_brs, 2 * num_wfs + des_uc, des_br, des_wf = len(uc_designs), len(br_designs), len(wf_designs) + total_exec = len(records) + total_pass = sum(1 for r in records if r.get('outcome') == 'Pass') + total_fail = sum(1 for r in records if r.get('outcome') in ('Fail', 'Error')) + total_partial = sum(1 for r in records if any(res.get('status') == 'Partial' for res in r.get('results', []))) + rows = [ + ['Total Use Cases', num_ucs], ['Total Business Rules', num_brs], ['Total Workflows', num_wfs], + ['Required UC Tests', req_uc], ['Designed UC Tests', des_uc], + ['Required BR Tests', req_br], ['Designed BR Tests', des_br], + ['Required WF Tests', req_wf], ['Designed WF Tests', des_wf], + ['UC Adequacy %', f"{(des_uc / req_uc * 100) if req_uc else 0:.1f}%"], + ['BR Adequacy %', f"{(des_br / req_br * 100) if req_br else 0:.1f}%"], + ['WF Adequacy %', f"{(des_wf / req_wf * 100) if req_wf else 0:.1f}%"], + ['Total Tests Executed', total_exec], ['Total Pass', total_pass], ['Total Partial', total_partial], ['Total Fail', total_fail], + ['Strict Pass Rate %', f"{(total_pass / total_exec * 100) if total_exec else 0:.1f}%"] + ] + _write_csv('Module_Test_Summary.csv', ['Metric', 'Value'], rows) + return rows + +class ReportingTestRunner(DiscoverRunner): + def get_resultclass(self): return ReportingTestResult + def run_suite(self, suite, **kwargs): + result = ReportingTestResult() + suite.run(result) + return result + def suite_result(self, suite, result, **kwargs): + _ensure_reports_dir() + uc, br, wf = generate_uc_test_design(), generate_br_test_design(), generate_wf_test_design() + generate_execution_log(result.test_records, result.tester_name) + generate_defect_log(result.test_records) + generate_artifact_evaluation(result.test_records) + generate_module_test_summary(result.test_records, uc, br, wf) + print(f"\nReports saved to: {_REPORTS_DIR}\n") + return super().suite_result(suite, result, **kwargs) diff --git a/FusionIIIT/applications/patent_system/tests/specs/__init__.py b/FusionIIIT/applications/patent_system/tests/specs/__init__.py new file mode 100644 index 000000000..e843fc214 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/specs/__init__.py @@ -0,0 +1 @@ +# Package marker\n \ No newline at end of file diff --git a/FusionIIIT/applications/patent_system/tests/specs/business_rules.yaml b/FusionIIIT/applications/patent_system/tests/specs/business_rules.yaml new file mode 100644 index 000000000..6f2793523 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/specs/business_rules.yaml @@ -0,0 +1,779 @@ +business_rules: + + - id: "BR-PMS-001" + title: "Patent Application Must Contain All Required Fields" + type: "Constraint" + description: "Patent Application MUST include title, abstract, detailed description, claims, inventor information, and supporting documents when submitted for review." + inputs: + - "application_title: String (max 250 chars)" + - "application_abstract: String (max 500 chars)" + - "detailed_description: Text" + - "patent_claims: Text" + - "inventor_details: JSON object" + - "supporting_documents: List" + - "required_fields_list: Configuration" + - "required_documents_list: Configuration" + logic: > + IF (title IS NOT NULL AND abstract IS NOT NULL AND description IS NOT NULL + AND claims IS NOT NULL AND inventors IS NOT NULL AND documents.count >= 1 + AND all required_documents_list items are present) + THEN validation_passed = TRUE + ELSE validation_passed = FALSE + + valid_tests: + - input_action: "Submit application with all fields filled: title, abstract, description, claims, inventor_details, and at least 1 document" + expected_result: "validation_passed = TRUE; application proceeds to submission" + + - input_action: "Submit application with exactly the minimum required document count (1 document)" + expected_result: "validation_passed = TRUE; application accepted" + + invalid_tests: + - input_action: "Submit application with missing patent_claims field" + expected_result: "validation_passed = FALSE; system highlights missing field; submission blocked" + + - input_action: "Submit application with no supporting documents uploaded" + expected_result: "validation_passed = FALSE; documents.count = 0; error returned" + + - input_action: "Submit application with abstract exceeding 500 characters" + expected_result: "validation_passed = FALSE; character limit error returned" + + effects: "Ensures all patent applications contain minimum required information for review and prevents submission of incomplete applications" + referenced_by: + use_cases: ["PMS-UC-001", "PMS-UC-003", "PMS-UC-008"] + workflows: ["PMS-WF-101", "PMS-WF-401"] + + + - id: "BR-PMS-002" + title: "User Must Have Appropriate Role for Patent Operations" + type: "Authorization" + description: "User MUST have valid role assignment (Applicant, Director, PCC_ADMIN, or Attorney) and be a registered Faculty or Staff member when accessing patent management functions." + inputs: + - "user_role: String from user session" + - "required_role: String from system configuration" + - "operation_type: String identifying the requested operation" + - "registration_status: String" + - "permission_matrix: Configuration" + logic: > + IF user_role IN [required_roles_for_operation] + AND applicant_role IN ['Faculty', 'Staff'] + AND registration_status = 'Active' + THEN allow_access = TRUE + ELSE allow_access = FALSE. + Log all access attempts. + + valid_tests: + - input_action: "Faculty member with 'Applicant' role and Active status attempts to submit an application" + expected_result: "allow_access = TRUE; operation permitted; access attempt logged" + + - input_action: "PCC_ADMIN with Active status attempts to assign a Director" + expected_result: "allow_access = TRUE; assignment operation permitted" + + invalid_tests: + - input_action: "User without any role assignment attempts to access patent submission form" + expected_result: "allow_access = FALSE; authorization error returned; attempt logged" + + - input_action: "Student (non-Faculty/Staff) attempts to submit application" + expected_result: "allow_access = FALSE; registration_status check fails; blocked" + + - input_action: "Inactive registered user attempts to log in and access patent functions" + expected_result: "allow_access = FALSE; registration_status = Inactive; access denied" + + effects: "Enforces role-based access control; only authorized personnel can access patent functions with complete audit trail" + referenced_by: + use_cases: ["PMS-UC-001", "PMS-UC-002", "PMS-UC-004", "PMS-UC-008"] + workflows: ["PMS-WF-101", "PMS-WF-401"] + + + - id: "BR-PMS-003" + title: "Conflict of Interest Must Be Declared and Prevented" + type: "Authorization" + description: "Director or PCC_ADMIN MUST NOT review, approve, or manage any application where they are listed as an inventor and MUST declare any conflicts of interest." + inputs: + - "reviewer_id: user id" + - "inventors: list (application data)" + - "conflict_type: String" + - "declaration_status: Boolean" + - "inventor_relationships: Database" + - "attorney_relationships: Database" + logic: > + IF reviewer_id IN inventors OR conflict_declared = TRUE + THEN block_assignment AND reassign_application + ELSE allow_review. + Before attorney assignment, run COI check: + IF conflict_detected THEN block_assignment AND alert PCC_ADMIN. + + valid_tests: + - input_action: "Director with no inventor relationship reviews assigned application" + expected_result: "COI check passes; review allowed to proceed" + + - input_action: "Attorney assigned to application with no declared conflict" + expected_result: "COI check passes; assignment confirmed" + + invalid_tests: + - input_action: "Director who is listed as an inventor on the application attempts to review it" + expected_result: "Assignment blocked; application reassigned; PCC_ADMIN alerted" + + - input_action: "Attorney with a declared conflict of interest is assigned to an application" + expected_result: "Assignment blocked; PCC_ADMIN alerted to select alternate attorney" + + effects: "Prevents conflict of interest, ensures unbiased review process, maintains ethical standards and objective patent assessment" + referenced_by: + use_cases: ["PMS-UC-002", "PMS-UC-003", "PMS-UC-006", "PMS-UC-007", "PMS-UC-009", "PMS-UC-019"] + workflows: ["PMS-WF-401"] + + + - id: "BR-PMS-004" + title: "Application Must Follow Sequential Review Process" + type: "Constraint" + description: "Application MUST be approved by a Director before it can be assigned to an Attorney for patentability assessment and Director must select one of three final decisions: 'Approve', 'Reject', or 'Needs Revision'." + inputs: + - "application_status: String" + - "review_completion_date: DateTime" + - "assignment_date: DateTime" + - "director_decision: String" + - "valid_decisions: Configuration" + logic: > + IF application_status == "Approved" + AND (review_completion_date - assignment_date) <= 15 business_days + AND director_decision IN {'Approve', 'Reject', 'Needs Revision'} + THEN allow_attorney_assignment + ELSE block + + valid_tests: + - input_action: "Director submits decision='Approve' within 15 business days of assignment" + expected_result: "Attorney assignment enabled; sequential workflow proceeds" + + - input_action: "Director submits decision='Needs Revision' with justification" + expected_result: "Decision recorded; applicant notified; revision workflow triggered" + + invalid_tests: + - input_action: "Attorney assignment attempted before Director has issued any decision" + expected_result: "Assignment blocked; application_status is not 'Approved'" + + - input_action: "Director attempts to submit an invalid decision value (e.g., 'Hold')" + expected_result: "Decision rejected; only Approve/Reject/Needs Revision are valid" + + effects: "Ensures proper review sequencing, maintains application processing timelines, and structures workflow with clear decision points" + referenced_by: + use_cases: ["PMS-UC-002", "PMS-UC-003", "PMS-UC-006", "PMS-UC-007"] + workflows: ["PMS-WF-401"] + + + - id: "BR-PMS-005" + title: "Director Must Provide Justification for Decisions" + type: "Constraint" + description: "Director MUST provide detailed justification when approving, rejecting, or requesting revisions to patent applications." + inputs: + - "action: String" + - "feedback: String" + - "justification_text: Text" + - "justification_length: Integer" + logic: > + IF action == "Reject" OR decision_type requires Justification + THEN feedback IS NOT NULL AND justification_length >= 50 + ELSE allow + + valid_tests: + - input_action: "Director rejects application with justification_text of 75 characters" + expected_result: "Decision accepted; feedback saved; applicant notified" + + - input_action: "Director requests revision with detailed feedback exceeding 50 chars" + expected_result: "Revision request accepted; applicant receives detailed feedback" + + invalid_tests: + - input_action: "Director rejects application without providing any justification" + expected_result: "Decision blocked; justification field is required error returned" + + - input_action: "Director rejects application with justification_text of only 20 characters" + expected_result: "Decision blocked; minimum 50 characters required for justification" + + effects: "Guarantees justification for decisions and ensures transparency in review process" + referenced_by: + use_cases: ["PMS-UC-003", "PMS-UC-007", "PMS-UC-018"] + workflows: [] + + + - id: "BR-PMS-006" + title: "Document Access Must Be Controlled Based on Status" + type: "Authorization" + description: "Applicants MUST NOT edit or manage documents unless application status is 'Awaiting Revision' and access must be role-based for confidential information with version control maintained." + inputs: + - "application_status: String" + - "user_role: Enum" + - "portfolio_section: Enum" + - "document_sensitivity: Enum" + - "document_id: String" + - "version_number: String" + - "auto_save_enabled: Boolean" + logic: > + IF application_status == "Awaiting Revision" + THEN allow_edit FOR applicant + AND IF user_role has permission for portfolio_section + THEN allow_access ELSE block. + IF document_modified + THEN increment_version_number AND create_version_record. + + valid_tests: + - input_action: "Applicant edits document when application_status = 'Awaiting Revision'" + expected_result: "Edit allowed; version incremented; version record created" + + - input_action: "PCC_ADMIN accesses confidential document with appropriate role permission" + expected_result: "Access granted; access event logged" + + invalid_tests: + - input_action: "Applicant attempts to edit document when application_status = 'Submitted'" + expected_result: "Edit blocked; document is locked post-submission" + + - input_action: "User without permission for a confidential portfolio_section attempts access" + expected_result: "Access denied; authorization error returned" + + effects: "Prevents document changes after submission except during revision phase, maintains confidentiality, and preserves document integrity with change history" + referenced_by: + use_cases: ["PMS-UC-001", "PMS-UC-003", "PMS-UC-004", "PMS-UC-005", "PMS-UC-009", "PMS-UC-016"] + workflows: ["PMS-WF-201"] + + + - id: "BR-PMS-007" + title: "Attorney Assignment Must Follow Institutional Policy" + type: "Authorization" + description: "Only PCC_ADMIN can assign attorneys and attorneys must be selected from the approved panel with relevant expertise unless Director grants exception." + inputs: + - "user_role: String" + - "attorney_panel: List" + - "specialization: String" + - "applicant_preference: String" + - "assignment_authority: Boolean" + - "application_category: Enum" + - "attorney_expertise_list: List" + logic: > + IF user_role = "PCC_ADMIN" + AND attorney IN approved_panel + AND specialization.matches(application_domain) + THEN IF applicant_preference IN approved_panel + THEN assign(applicant_preference) + ELSE assign(best_match). + Filter attorney_approved_list WHERE + attorney_expertise_list CONTAINS application_category. + + valid_tests: + - input_action: "PCC_ADMIN assigns attorney from approved panel whose specialization matches application domain" + expected_result: "Assignment successful; attorney notified" + + - input_action: "PCC_ADMIN assigns attorney matching applicant's preferred choice from approved panel" + expected_result: "Applicant preference honored; assignment confirmed" + + invalid_tests: + - input_action: "Director (non-PCC_ADMIN) attempts to directly assign an attorney" + expected_result: "Assignment blocked; only PCC_ADMIN has assignment authority" + + - input_action: "PCC_ADMIN attempts to assign attorney not on the approved panel" + expected_result: "Assignment blocked; attorney not in approved_panel" + + effects: "Maintains control over attorney assignments, ensures qualified legal representation, and improves patentability assessment quality" + referenced_by: + use_cases: ["PMS-UC-006", "PMS-UC-007", "PMS-UC-019"] + workflows: [] + + + - id: "BR-PMS-008" + title: "Budget Must Be Approved for Patent Applications" + type: "Authorization" + description: "Budget MUST NOT be approved for applications whose status is not 'Approved' and filing costs above threshold must be escalated to Director with proper authorization limits." + inputs: + - "application_status: String" + - "filing_cost: Currency" + - "attorney_fees: Currency" + - "threshold_limit: Currency" + - "budget_availability: Currency" + - "transaction_amount: Currency" + - "user_authority_level: Enum" + - "authorization_limit: Currency" + logic: > + total_cost = filing_cost + attorney_fees + administrative_cost; + IF application_status == "Approved" + AND total_cost <= threshold_limit + AND budget_availability >= total_cost + THEN allow_budget_approval + ELSE require_director_approval. + IF transaction_amount <= authorization_limit + THEN approve_transaction + ELSE require_higher_authority. + + valid_tests: + - input_action: "Budget request submitted for Approved application with total_cost within threshold and sufficient budget" + expected_result: "Budget approved; financial tracking initiated" + + - input_action: "Transaction amount is within PCC_ADMIN authorization limit" + expected_result: "Transaction approved without escalation" + + invalid_tests: + - input_action: "Budget request submitted for application with status = 'Under Review'" + expected_result: "Budget approval blocked; application must be Approved first" + + - input_action: "Total cost exceeds threshold_limit; budget not escalated" + expected_result: "Auto-escalation to Director triggered; PCC_ADMIN cannot approve unilaterally" + + - input_action: "Budget requested when budget_availability < total_cost" + expected_result: "Approval blocked; insufficient budget error returned" + + effects: "Ensures budgets approved only for validated applications, controls patent-related expenditure, and prevents budget overruns" + referenced_by: + use_cases: ["PMS-UC-006", "PMS-UC-008", "PMS-UC-009", "PMS-UC-012", "PMS-UC-018"] + workflows: ["PMS-WF-301"] + + + - id: "BR-PMS-009" + title: "Post-Grant Tracking Must Be Properly Activated" + type: "Constraint + Trigger" + description: "Post-grant tracking MUST ONLY be activated for patents whose status is 'Granted' and system must calculate maintenance fee schedules with explicit renewal decisions required." + inputs: + - "patent_status: String" + - "grant_date: Date" + - "maintenance_schedule: Configuration" + - "fee_amount: Currency" + - "renewal_dates: Array" + - "maintenance_fee_schedule: Configuration" + logic: > + IF patent_status == "Granted" + THEN allow_activation + AND CALCULATE next_fee_date = grant_date + maintenance_schedule.interval + ELSE block. + ON application_status CHANGE to 'Patent Granted' + THEN create PostGrantRecord with renewal deadline reminders. + Payment disabled until explicit renewal decision recorded. + + valid_tests: + - input_action: "Patent status changes to 'Granted'; system activates post-grant tracking" + expected_result: "PostGrantRecord created; maintenance schedule calculated; renewal reminders set" + + - input_action: "Next fee date calculated as grant_date + defined maintenance_schedule interval" + expected_result: "Correct next_fee_date stored; deadline tracker active" + + invalid_tests: + - input_action: "Post-grant tracking activation attempted for application with status = 'Approved' (not yet Granted)" + expected_result: "Activation blocked; patent_status must be 'Granted'" + + - input_action: "Payment processed without an explicit renewal decision on record" + expected_result: "Payment blocked; explicit renewal decision required first" + + effects: "Restricts post-grant tracking to granted patents, maintains patent validity through timely fee payments, and prevents accidental patent loss" + referenced_by: + use_cases: ["PMS-UC-010", "PMS-UC-012", "PMS-UC-013", "PMS-UC-014"] + workflows: ["PMS-WF-501"] + + + - id: "BR-PMS-010" + title: "Document Upload Must Meet Security Requirements" + type: "Constraint" + description: "Uploaded documents MUST be in PDF format, under 50MB, malware-free, and classified by security level with comprehensive encryption at rest and in transit." + inputs: + - "file_format: String" + - "file_size: Integer (bytes)" + - "malware_scan_result: Boolean" + - "document_type: Enum" + - "content_sensitivity: Enum" + - "data_encryption_status: Boolean" + - "encryption_standard: Configuration" + - "key_management: Service" + logic: > + IF file_format = "PDF" + AND file_size <= 52428800 + AND malware_scan_result = CLEAN + THEN upload_allowed = TRUE + AND classify_document(content_sensitivity) + ELSE upload_denied. + Apply encryption using AES256; + IF encryption fails THEN abort_operation AND alert_security_team. + + valid_tests: + - input_action: "Upload a 10MB PDF file that passes malware scan" + expected_result: "upload_allowed = TRUE; document classified; encrypted and stored" + + - input_action: "Upload a 52MB (exactly at limit) clean PDF" + expected_result: "upload_allowed = TRUE; file accepted at boundary" + + invalid_tests: + - input_action: "Upload a .docx file instead of PDF" + expected_result: "upload_denied; file_format mismatch error returned" + + - input_action: "Upload a PDF larger than 50MB (e.g., 60MB)" + expected_result: "upload_denied; file size exceeds 50MB limit" + + - input_action: "Upload a PDF that fails malware scan" + expected_result: "upload_denied; malware detected error returned; security team alerted" + + effects: "Ensures document security, system compatibility, protects intellectual property through appropriate access controls and comprehensive encryption" + referenced_by: + use_cases: ["PMS-UC-001", "PMS-UC-012", "PMS-UC-013"] + workflows: ["PMS-WF-101"] + + + - id: "BR-PMS-011" + title: "System Must Send Automated Notifications" + type: "Trigger" + description: "System MUST send automated notifications to relevant stakeholders when patent application status changes and generate alerts for approaching deadlines with SLA escalation." + inputs: + - "application_id: String" + - "old_status: String" + - "new_status: String" + - "stakeholder_list: Array" + - "deadline_date: Date" + - "days_remaining: Integer" + - "task_type: String" + - "task_assigned_time: DateTime" + - "escalation_chain: Configuration" + logic: > + IF old_status != new_status + THEN send_notification(stakeholder_list, notification_type) + AND IF days_remaining IN [30, 15, 5] + THEN generate_alert(alert_recipients, deadline_date). + IF (current_time - task_assigned_time) >= policy_thresholds(task_type).escalation + THEN escalate to next authority per escalation_chain. + + valid_tests: + - input_action: "Application status changes from 'Submitted' to 'Under Review'" + expected_result: "Notification dispatched to applicant and Director; event logged in audit trail" + + - input_action: "Deadline is 5 days away" + expected_result: "Alert generated and sent to responsible party (days_remaining = 5 triggers alert)" + + invalid_tests: + - input_action: "Status change event fires but stakeholder email is invalid" + expected_result: "Delivery failure logged; PCC_ADMIN alerted to correct contact details" + + - input_action: "Task assigned time exceeds SLA threshold with no escalation triggered" + expected_result: "Escalation fires per escalation_chain; next authority notified" + + effects: "Ensures all stakeholders are informed of changes, prevents missed deadlines, ensures timely action, and improves timeliness of patent process" + referenced_by: + use_cases: ["PMS-UC-001", "PMS-UC-002", "PMS-UC-003", "PMS-UC-004", "PMS-UC-007", "PMS-UC-008", "PMS-UC-016"] + workflows: ["PMS-WF-301", "PMS-WF-401", "PMS-WF-501", "PMS-WF-601"] + + + - id: "BR-PMS-012" + title: "Applications Must Be Assigned Based on Expertise and Hierarchy" + type: "Calculation + Authorization" + description: "System MUST assign patent applications to directors when expertise domain matches application category and consider workload balance with proper hierarchical routing." + inputs: + - "application_category: Enum" + - "director_expertise: List" + - "director_workload: Integer" + - "availability_status: Boolean" + - "personnel_expertise: List" + - "applicant_department: String" + - "hierarchy_graph: Configuration" + - "routing_rules: Configuration" + logic: > + Decision Table: Domain Match + Workload + Availability → Assignment; + IF domain_match AND workload < threshold AND availability = TRUE + THEN assign to lowest_workload_director. + ON application_status CHANGE to 'Submitted' + THEN route to PCC_ADMIN. + Filter WHERE director_department = applicant_department + OR expertise matches application_field_of_invention. + + valid_tests: + - input_action: "Application submitted; Director A has matching domain and workload below threshold" + expected_result: "Application routed to Director A; assignment confirmed; Director notified" + + - input_action: "Two eligible Directors available; Director B has lower workload" + expected_result: "Application assigned to Director B (lowest workload wins)" + + invalid_tests: + - input_action: "All Directors with matching domain expertise are above workload threshold" + expected_result: "Assignment fails; PCC_ADMIN alerted; manual assignment required" + + - input_action: "Application submitted but no Director has expertise matching application_category" + expected_result: "No auto-assignment made; PCC_ADMIN alerted to handle manually" + + effects: "Ensures proper expertise-based assignment, workload distribution, centralizes intake process, and maintains proper approval hierarchy" + referenced_by: + use_cases: ["PMS-UC-002", "PMS-UC-011", "PMS-UC-014", "PMS-UC-019"] + workflows: ["PMS-WF-401"] + + + - id: "BR-PMS-013" + title: "Inventor Agreement Must Be Obtained" + type: "Constraint" + description: "All inventors must sign an inventor agreement before filing and electronic signatures are permitted where legally allowed with proper validation." + inputs: + - "inventor_agreements_signed: Boolean" + - "signature_audit: Object" + - "validity_checks: Boolean" + - "legal_permission: Boolean" + - "signature_data: Object" + logic: > + IF inventor_agreements_signed = TRUE + AND (physical_signature OR e_signature_valid) + THEN proceed_with_filing + ELSE block_submission. + Validate e-signatures and store signature_audit with validity_checks. + + valid_tests: + - input_action: "All inventors have signed agreement (physically or via valid e-signature)" + expected_result: "Submission proceeds; signature_audit stored" + + - input_action: "Co-inventor signs via e-signature in a jurisdiction where it is legally permitted" + expected_result: "e_signature_valid = TRUE; agreement accepted; audit logged" + + invalid_tests: + - input_action: "Application submitted with one co-inventor agreement unsigned" + expected_result: "Submission blocked; unsigned agreement error; reminder sent to co-inventor" + + - input_action: "E-signature submitted but fails validation checks" + expected_result: "Submission blocked; invalid e-signature error; re-signing required" + + effects: "Ensures ownership and consent before filing with proper signature validation and comprehensive documentation" + referenced_by: + use_cases: ["PMS-UC-001", "PMS-UC-016", "PMS-UC-017"] + workflows: ["PMS-WF-101"] + + + - id: "BR-PMS-014" + title: "Patentability Assessment Must Follow Legal Framework" + type: "Constraint" + description: "Assigned attorney must produce a patentability opinion following established legal framework before filing and must provide clear recommendation with detailed justification." + inputs: + - "opinion_recorded: Boolean" + - "novelty_score: Float" + - "non_obviousness_score: Float" + - "utility_score: Float" + - "search_completeness: Percentage" + - "assessment_recommendation: String" + - "attorney_report: Text" + logic: > + IF opinion_recorded = TRUE + AND novelty_score >= threshold + AND non_obviousness_score >= legal_standard + AND utility_score >= threshold + AND search_completeness >= minimum + AND assessment_recommendation IN {'File Patent', 'Do Not File'} + AND attorney_report IS NOT NULL + THEN allow_filing + ELSE block + + valid_tests: + - input_action: "Attorney submits full assessment with all scores above threshold, valid recommendation, and non-null report" + expected_result: "Filing allowed; assessment stored; application progresses" + + - input_action: "Attorney recommends 'Do Not File' with complete scores and report" + expected_result: "Assessment accepted; application flagged accordingly; Director notified" + + invalid_tests: + - input_action: "Filing attempted before attorney has recorded any patentability opinion" + expected_result: "Filing blocked; opinion_recorded = FALSE" + + - input_action: "Assessment submitted with novelty_score below threshold" + expected_result: "Filing blocked; novelty score does not meet legal standard" + + - input_action: "Recommendation value submitted as 'Maybe' (not in valid set)" + expected_result: "Assessment rejected; only 'File Patent' or 'Do Not File' are accepted" + + effects: "Ensures thorough legal analysis, comprehensive prior art evaluation before filing, clear legal opinion, and creates audit trail of legal assessment" + referenced_by: + use_cases: ["PMS-UC-007", "PMS-UC-020"] + workflows: [] + + + - id: "BR-PMS-015" + title: "Application Priority Must Be Based on Submission Date" + type: "Calculation" + description: "System MUST assign processing priority based on application submission date and strategic factors when multiple applications await review." + inputs: + - "submission_date: Date" + - "deadline_date: Date" + - "strategic_value: Enum" + - "urgency_level: Enum" + - "priority_score: Integer" + logic: > + IF special_circumstances = FALSE + THEN priority_score = MAX_PRIORITY - days_since_submission + ELSE priority_score = (Deadline Weight × Days Until Deadline) + + (Strategic Value × Weight) + + (Urgency × Weight) + + valid_tests: + - input_action: "Two standard applications submitted 10 and 5 days ago respectively; no special circumstances" + expected_result: "Older application (10 days) receives higher priority_score; processed first" + + - input_action: "Application with high strategic_value and urgent urgency_level flagged under special_circumstances" + expected_result: "Composite priority_score calculated using weighted formula; application prioritized accordingly" + + invalid_tests: + - input_action: "Newly submitted application (today) placed ahead of application submitted 30 days ago without special circumstances" + expected_result: "Priority calculation corrected; older application has higher priority_score" + + effects: "Ensures fair processing order, prevents indefinite delays, and ensures critical applications receive appropriate attention" + referenced_by: + use_cases: ["PMS-UC-002", "PMS-UC-003", "PMS-UC-018"] + workflows: ["PMS-WF-401"] + + + - id: "BR-PMS-016" + title: "Application Revision Must Follow Time Limits" + type: "Constraint" + description: "Applicant MUST resubmit revised application within 60 days when rejection notice is issued and feedback must be addressed with controlled withdrawal process." + inputs: + - "rejection_date: Date" + - "resubmission_date: Date" + - "revision_period: Integer" + - "feedback_addressed: Boolean" + - "status_change_valid: Boolean" + - "withdraw_reason: String" + - "non_withdrawable_statuses: Configuration" + logic: > + IF (resubmission_date - rejection_date) <= 60 days + AND feedback_addressed = TRUE + AND valid_state_transition + THEN allow_resubmission = TRUE + ELSE application_status = 'EXPIRED'. + IF application_status NOT IN non_withdrawable_statuses + THEN allow_withdrawal with required acknowledgment. + + valid_tests: + - input_action: "Applicant resubmits on day 45 after rejection with all feedback addressed" + expected_result: "allow_resubmission = TRUE; new review cycle triggered" + + - input_action: "Applicant withdraws application while status = 'Needs Revision' (not in non_withdrawable_statuses)" + expected_result: "Withdrawal allowed with acknowledgment; application locked" + + invalid_tests: + - input_action: "Applicant attempts to resubmit on day 65 after rejection" + expected_result: "Resubmission blocked; application_status changed to EXPIRED" + + - input_action: "Applicant resubmits within 60 days but feedback_addressed = FALSE" + expected_result: "Resubmission blocked; feedback must be addressed before resubmission" + + - input_action: "Applicant attempts to withdraw application with status = 'Filed' (in non_withdrawable_statuses)" + expected_result: "Withdrawal blocked; status is non-withdrawable; PCC_ADMIN authorization required" + + effects: "Provides fair opportunity for improvement while maintaining process efficiency, workflow integrity, and allows controlled withdrawal process" + referenced_by: + use_cases: ["PMS-UC-004", "PMS-UC-008", "PMS-UC-014", "PMS-UC-018"] + workflows: ["PMS-WF-201"] + + + - id: "BR-PMS-017" + title: "External Filing Must Follow Patent Office Requirements" + type: "Constraint" + description: "Patent application MUST comply with national patent office format when submitted externally and communications must follow established protocols with proper justification for international filings." + inputs: + - "application_format: Object" + - "patent_office: String" + - "format_compliance: Boolean" + - "protocol_requirements: List" + - "communication_type: Enum" + - "filing_office: String" + - "justification_text: Text" + - "external_id: String" + - "jurisdiction: String" + logic: > + IF application_format.validates_against(patent_office.requirements) + AND required_fields.complete = TRUE + AND protocol_compliance = TRUE + THEN submission_ready = TRUE + ELSE format_correction_required. + IF filing_office != 'Indian Patent Office' + THEN justification_text becomes mandatory field. + + valid_tests: + - input_action: "Application formatted per Indian Patent Office requirements; all required fields complete" + expected_result: "submission_ready = TRUE; application dispatched to patent office" + + - input_action: "Application filed with a foreign office; justification_text is provided" + expected_result: "International filing accepted; justification recorded" + + invalid_tests: + - input_action: "Application submitted with missing required fields per patent office schema" + expected_result: "format_correction_required; error list returned to attorney" + + - input_action: "Application filed with non-Indian patent office without justification_text" + expected_result: "Filing blocked; justification_text is mandatory for international filings" + + effects: "Ensures successful external filing, reduces rejection risk, ensures proper patent office interactions, and maintains mapping with external offices" + referenced_by: + use_cases: ["PMS-UC-007", "PMS-UC-009", "PMS-UC-010", "PMS-UC-013"] + workflows: ["PMS-WF-601"] + + + - id: "BR-PMS-018" + title: "System Must Maintain Complete Audit Trail" + type: "Constraint" + description: "System MUST log all patent application actions and status changes with complete audit trail for compliance and record all critical operations with immutable records." + inputs: + - "user_id: String" + - "action_type: Enum" + - "timestamp: DateTime" + - "application_id: String" + - "previous_state: String" + - "new_state: String" + - "change_details: Text" + - "user_action: String" + - "document_access: Object" + logic: > + FOR EACH application_action + THEN log_entry = create_audit_record(user_id, action_type, timestamp, + application_id, previous_state, new_state) + AND IF action_type IN critical_operations + THEN create_immutable_record. + Log all critical operations to immutable audit store. + + valid_tests: + - input_action: "Applicant submits application; action_type = 'SUBMIT'" + expected_result: "Audit record created with user_id, timestamp, previous_state=Draft, new_state=Submitted" + + - input_action: "Director issues Rejection decision (critical operation)" + expected_result: "Immutable audit record created; cannot be modified or deleted" + + invalid_tests: + - input_action: "Status change occurs without triggering audit log creation" + expected_result: "Compliance violation detected; missing audit record flagged for investigation" + + - input_action: "Attempt to modify an existing immutable audit record" + expected_result: "Modification blocked; immutable record cannot be altered" + + effects: "Provides complete transaction history for compliance, troubleshooting, audit purposes, and enables forensic analysis if needed" + referenced_by: + use_cases: ["PMS-UC-001", "PMS-UC-002", "PMS-UC-003", "PMS-UC-004", "PMS-UC-005", "PMS-UC-006", + "PMS-UC-007", "PMS-UC-008", "PMS-UC-009", "PMS-UC-010", "PMS-UC-011", "PMS-UC-012", + "PMS-UC-013", "PMS-UC-014", "PMS-UC-015", "PMS-UC-016", "PMS-UC-017", "PMS-UC-018", + "PMS-UC-019", "PMS-UC-020"] + workflows: ["PMS-WF-201", "PMS-WF-301", "PMS-WF-401", "PMS-WF-501", "PMS-WF-601"] + + + - id: "BR-PMS-019" + title: "Legal Advice Must Be Properly Documented" + type: "Constraint" + description: "Attorney legal advice MUST be documented with proper attribution, confidentiality markings, and attorney-client privilege protection with research tool availability." + inputs: + - "advice_content: Text" + - "attorney_id: String" + - "confidentiality_level: Enum" + - "privilege_status: Boolean" + - "research_query: String" + - "tool_availability: Boolean" + logic: > + IF legal_advice_provided + THEN create_documentation_record + AND mark_confidentiality_level + AND log_attorney_attribution + AND set_privilege_status = TRUE + + valid_tests: + - input_action: "Attorney provides legal opinion on patentability; advice_content is non-null" + expected_result: "Documentation record created; confidentiality_level marked; attorney_id attributed; privilege_status = TRUE" + + - input_action: "Attorney documents legal research with proper attribution and confidentiality" + expected_result: "Record stored with all required fields; accessible only to authorized roles" + + invalid_tests: + - input_action: "Legal advice stored without attorney_id attribution" + expected_result: "Record rejected; attorney attribution is mandatory" + + - input_action: "Legal advice document saved with privilege_status = FALSE" + expected_result: "System override sets privilege_status = TRUE; attorney-client privilege enforced" + + effects: "Ensures legal advice is properly recorded, protected, and supports comprehensive legal analysis" + referenced_by: + use_cases: ["PMS-UC-007"] + workflows: [] diff --git a/FusionIIIT/applications/patent_system/tests/specs/use_cases.yaml b/FusionIIIT/applications/patent_system/tests/specs/use_cases.yaml new file mode 100644 index 000000000..123126c64 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/specs/use_cases.yaml @@ -0,0 +1,536 @@ +use_cases: + + - id: "PMS-UC-001" + title: "Submit Patent Application" + description: "Allows an applicant (Faculty/Staff/Applicant) to create and formally submit a new patent application into the system for review and processing." + actors: "Primary: Applicant; Secondary: PCC_ADMIN" + preconditions: "Applicant logged in with 'Applicant' role; all necessary documents and details are ready; required agreements signed" + + happy_paths: + - scenario: "Applicant submits a complete application with valid documents" + preconditions: "Applicant is logged in and authorized" + input_action: "POST /patent/application with title, abstract, description, claims, inventors, uploaded_documents" + expected_result: "PatentApplication created with status=Submitted; confirmation email sent; appears on applicant dashboard" + + alternate_paths: + - scenario: "Applicant saves an incomplete application as draft" + preconditions: "Applicant is logged in" + input_action: "POST /patent/application with partial fields, action=save_draft" + expected_result: "Application saved as draft; no notification sent to PCC_ADMIN" + + exception_paths: + - scenario: "Unauthorized user attempts to submit" + preconditions: "User is logged in without 'Applicant' role" + input_action: "POST /patent/application" + expected_result: "Request rejected with authorization error; submission blocked" + + - scenario: "Applicant submits incomplete application" + preconditions: "Applicant is logged in" + input_action: "POST /patent/application with missing required fields" + expected_result: "System highlights missing fields; application not submitted; returns to form" + + - scenario: "Document validation fails during upload" + preconditions: "Applicant is logged in" + input_action: "POST /patent/application/documents with invalid file format" + expected_result: "Error displayed for invalid document; retry upload prompted" + + business_rules: ["BR-PMS-001", "BR-PMS-002", "BR-PMS-010", "BR-PMS-013", "BR-PMS-018"] + workflows: ["PMS-WF-101", "PMS-WF-401"] + + + - id: "PMS-UC-002" + title: "Assign Application to Director" + description: "Enables PCC_ADMIN or system to assign newly submitted patent applications to appropriate Director(s) based on expertise or workload." + actors: "Primary: PCC_ADMIN, System; Secondary: Director" + preconditions: "Application exists with status=Submitted; Directors have defined expertise and workloads" + + happy_paths: + - scenario: "System auto-assigns application to Director based on expertise match" + preconditions: "Application status is Submitted; eligible Directors are available" + input_action: "PATCH /patent/application/{id}/assign with director_selection=auto" + expected_result: "Application status updated to Under Review; Director notified; review timeline initiated" + + alternate_paths: + - scenario: "PCC_ADMIN manually overrides auto-assignment" + preconditions: "Application is Submitted; PCC_ADMIN is logged in" + input_action: "PATCH /patent/application/{id}/assign with director_id={manual_id}" + expected_result: "Application assigned to selected Director; status updated; Director notified" + + exception_paths: + - scenario: "No eligible Director is available" + preconditions: "Application is Submitted; no Director with matching expertise has capacity" + input_action: "PATCH /patent/application/{id}/assign with director_selection=auto" + expected_result: "Assignment fails; retry scheduled; application remains Submitted" + + - scenario: "Assignment conflict detected" + preconditions: "Director already at max workload" + input_action: "PATCH /patent/application/{id}/assign with director_id={overloaded_id}" + expected_result: "Conflict flagged; manual override required; assignment not saved" + + business_rules: ["BR-PMS-002", "BR-PMS-003", "BR-PMS-004", "BR-PMS-011", "BR-PMS-012", "BR-PMS-015", "BR-PMS-018"] + workflows: ["PMS-WF-401"] + + + - id: "PMS-UC-003" + title: "Review Patent Application" + description: "Enables Director or Reviewer to access and review submissions, provide feedback, and issue decisions (Approve/Reject/Revision Required)." + actors: "Primary: Director; Secondary: Applicant, PCC_ADMIN" + preconditions: "Application assigned to Director with status=Under Review" + + happy_paths: + - scenario: "Director approves a complete and valid application" + preconditions: "Application is Under Review and assigned to Director" + input_action: "POST /patent/application/{id}/review with decision=Approved, feedback={comments}" + expected_result: "Application status updated to Approved; applicant notified; audit log updated" + + alternate_paths: + - scenario: "Director requests revision from applicant" + preconditions: "Application is Under Review" + input_action: "POST /patent/application/{id}/review with decision=Needs Revision, feedback={detailed_comments}" + expected_result: "Application status changed to Needs Revision; applicant notified with feedback" + + - scenario: "Director defers the review" + preconditions: "Application is Under Review" + input_action: "POST /patent/application/{id}/review with action=defer, note={reason}" + expected_result: "Deferral note added; timeline extended; application remains Under Review" + + exception_paths: + - scenario: "Review timeline is violated" + preconditions: "Application has exceeded review deadline" + input_action: "GET /patent/application/{id}/review_status" + expected_result: "Escalation logged; timeline violation recorded; PCC_ADMIN notified" + + business_rules: ["BR-PMS-001", "BR-PMS-003", "BR-PMS-004", "BR-PMS-005", "BR-PMS-006", "BR-PMS-011", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-004" + title: "Revise and Resubmit Application" + description: "Applicant revises application based on Director feedback and resubmits for further review." + actors: "Primary: Applicant; Secondary: Director" + preconditions: "Application status is Needs Revision or Rejected; revision is within the allowed time limit" + + happy_paths: + - scenario: "Applicant addresses all feedback and resubmits within 60 days" + preconditions: "Application is in Needs Revision status; within 60-day resubmission window" + input_action: "PUT /patent/application/{id}/revise with updated_fields, updated_documents" + expected_result: "Application status changed to Resubmitted; new review cycle triggered; stakeholders notified" + + alternate_paths: + - scenario: "Applicant partially updates and saves before final resubmission" + preconditions: "Application is in Needs Revision; within allowed window" + input_action: "PUT /patent/application/{id}/revise with action=save_draft" + expected_result: "Draft revision saved; version history maintained; no status change" + + exception_paths: + - scenario: "Applicant attempts to resubmit after the 60-day window has expired" + preconditions: "Application is in Needs Revision; 60-day deadline has passed" + input_action: "PUT /patent/application/{id}/revise" + expected_result: "Submission rejected with date/deadline error; status changed to Expired; applicant notified" + + - scenario: "Revised application does not address the feedback" + preconditions: "Application is in Needs Revision" + input_action: "PUT /patent/application/{id}/revise with unaddressed feedback items" + expected_result: "System rejects revision; applicant prompted to make further edits" + + business_rules: ["BR-PMS-002", "BR-PMS-006", "BR-PMS-011", "BR-PMS-016"] + workflows: ["PMS-WF-201"] + + + - id: "PMS-UC-005" + title: "Track Application Status" + description: "Allows applicants and administrators to view real-time status of patent applications throughout the lifecycle." + actors: "Primary: Applicant, PCC_ADMIN; Secondary: Director" + preconditions: "Application exists in the system; user has appropriate role to view" + + happy_paths: + - scenario: "Applicant views current status of their application" + preconditions: "Applicant is logged in; application exists in the system" + input_action: "GET /patent/application/{id}/status" + expected_result: "Current status, history timeline, and pending actions displayed" + + alternate_paths: + - scenario: "PCC_ADMIN views status of all applications in a batch" + preconditions: "PCC_ADMIN is logged in" + input_action: "GET /patent/applications?filter=all&sort=status" + expected_result: "Filtered and sorted list of applications with statuses displayed" + + exception_paths: + - scenario: "Applicant attempts to view another applicant's application" + preconditions: "Applicant is logged in" + input_action: "GET /patent/application/{other_id}/status" + expected_result: "Access denied with authorization error" + + business_rules: ["BR-PMS-002", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-006" + title: "Assign Attorney" + description: "Assigns a legal attorney to a patent application for legal assessment and filing support." + actors: "Primary: PCC_ADMIN; Secondary: Attorney, Director" + preconditions: "Application is in a state requiring legal involvement (e.g., Approved or Under Review)" + + happy_paths: + - scenario: "PCC_ADMIN assigns available attorney to application" + preconditions: "Application requires legal review; attorney is available" + input_action: "PATCH /patent/application/{id}/assign_attorney with attorney_id={id}" + expected_result: "Attorney assigned; application updated; attorney notified" + + exception_paths: + - scenario: "No attorney is available for assignment" + preconditions: "All attorneys are at capacity" + input_action: "PATCH /patent/application/{id}/assign_attorney with attorney_selection=auto" + expected_result: "Assignment fails; PCC_ADMIN alerted to manually resolve" + + business_rules: ["BR-PMS-003", "BR-PMS-004", "BR-PMS-012", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-007" + title: "Assess Application Patentability (Legal Assessment)" + description: "Attorney or Director conducts legal assessment to evaluate patentability of the submitted application." + actors: "Primary: Attorney; Secondary: Director, PCC_ADMIN" + preconditions: "Application is Approved or Under Review; attorney assigned" + + happy_paths: + - scenario: "Attorney completes legal assessment and marks application as patentable" + preconditions: "Attorney is assigned and application is accessible" + input_action: "POST /patent/application/{id}/legal_assessment with result=Patentable, notes={details}" + expected_result: "Legal assessment recorded; application progresses to filing stage" + + exception_paths: + - scenario: "Attorney finds significant legal conflict; application is not patentable" + preconditions: "Attorney is assigned" + input_action: "POST /patent/application/{id}/legal_assessment with result=Not Patentable, notes={reason}" + expected_result: "Application flagged; Director and applicant notified; further action required" + + business_rules: ["BR-PMS-003", "BR-PMS-005", "BR-PMS-007", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-008" + title: "Manage Budgets and Financial Approval" + description: "Handles budget allocation, cost estimation, and financial approvals for patent-related expenditures." + actors: "Primary: PCC_ADMIN; Secondary: Director" + preconditions: "Application status is Approved; cost estimates for fees are available" + + happy_paths: + - scenario: "PCC_ADMIN approves budget within threshold" + preconditions: "Budget request submitted; amount is within PCC_ADMIN approval limit" + input_action: "POST /patent/application/{id}/budget_approval with amount={value}, approved_by=PCC_ADMIN" + expected_result: "Budget approved; financial tracking initiated; stakeholders notified" + + alternate_paths: + - scenario: "Budget exceeds PCC_ADMIN threshold; escalated to Director" + preconditions: "Budget request submitted; amount exceeds PCC_ADMIN authority" + input_action: "POST /patent/application/{id}/budget_approval with amount={high_value}" + expected_result: "Request escalated to Director for approval" + + exception_paths: + - scenario: "Budget request denied by Director" + preconditions: "Escalated budget request is under Director review" + input_action: "POST /patent/application/{id}/budget_approval with decision=Denied" + expected_result: "Budget denied; applicant and PCC_ADMIN notified; application on hold" + + business_rules: ["BR-PMS-008", "BR-PMS-009", "BR-PMS-018"] + workflows: ["PMS-WF-301"] + + + - id: "PMS-UC-009" + title: "File with Patent Office / Log Official Filing" + description: "Handles the official submission of approved applications to the patent office and logs the filing details." + actors: "Primary: Attorney, PCC_ADMIN; Secondary: System" + preconditions: "Application is Approved; budget is approved; legal assessment complete" + + happy_paths: + - scenario: "Attorney files application with patent office and logs confirmation" + preconditions: "All approvals in place" + input_action: "POST /patent/application/{id}/official_filing with filing_date, office_reference_number" + expected_result: "Filing logged; application status updated to Filed; deadline tracking initiated" + + exception_paths: + - scenario: "Patent office rejects filing due to format error" + preconditions: "Application submitted to patent office" + input_action: "POST /patent/application/{id}/official_filing with malformed documents" + expected_result: "Filing error logged; attorney notified to correct and refile" + + business_rules: ["BR-PMS-009", "BR-PMS-013", "BR-PMS-018"] + workflows: ["PMS-WF-601"] + + + - id: "PMS-UC-010" + title: "Track Application Progress and Notifications" + description: "Provides real-time progress tracking and automated notifications to relevant stakeholders at each stage." + actors: "Primary: System; Secondary: Applicant, PCC_ADMIN, Director" + preconditions: "Application exists in the system" + + happy_paths: + - scenario: "System sends status-change notification to applicant automatically" + preconditions: "Application status changes to a new stage" + input_action: "SYSTEM_EVENT: application.status_changed with new_status={value}" + expected_result: "Notification dispatched to applicant and relevant parties; event logged in audit trail" + + exception_paths: + - scenario: "Notification delivery fails due to invalid email" + preconditions: "Status change event triggered" + input_action: "SYSTEM_EVENT: application.status_changed with invalid email on record" + expected_result: "Delivery failure logged; PCC_ADMIN alerted to update contact details" + + business_rules: ["BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-011" + title: "Respond to Office Actions / Amend and Resubmit" + description: "Enables attorney or applicant to respond to official queries (Office Actions) from the patent office and resubmit amendments." + actors: "Primary: Attorney; Secondary: Applicant, PCC_ADMIN" + preconditions: "An Office Action has been received and logged; application is Filed" + + happy_paths: + - scenario: "Attorney submits timely response to Office Action" + preconditions: "Office Action received; within response deadline" + input_action: "POST /patent/application/{id}/office_action_response with response_document, amendments" + expected_result: "Response filed; application status updated; deadline extended or closed" + + exception_paths: + - scenario: "Response deadline passes without submission" + preconditions: "Office Action response deadline exceeded" + input_action: "SYSTEM_EVENT: office_action.deadline_passed" + expected_result: "Application flagged as at-risk; escalation notification sent to PCC_ADMIN and Director" + + business_rules: ["BR-PMS-005", "BR-PMS-012", "BR-PMS-018"] + workflows: ["PMS-WF-601"] + + + - id: "PMS-UC-012" + title: "Track and Manage Deadlines" + description: "Tracks all critical deadlines (review timelines, resubmission windows, office action responses) and sends automated reminders." + actors: "Primary: System; Secondary: PCC_ADMIN, Attorney, Applicant" + preconditions: "Application has active deadlines in the system" + + happy_paths: + - scenario: "System sends deadline reminder 7 days before due date" + preconditions: "Deadline is approaching" + input_action: "SYSTEM_EVENT: deadline.approaching with days_remaining=7" + expected_result: "Reminder notification sent to responsible party; logged in audit trail" + + exception_paths: + - scenario: "Deadline is missed; escalation triggered" + preconditions: "Deadline has passed without action" + input_action: "SYSTEM_EVENT: deadline.missed" + expected_result: "Escalation created; PCC_ADMIN and Director notified; application flagged" + + business_rules: ["BR-PMS-012", "BR-PMS-015", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-013" + title: "Manage Maintenance Fees and Renewals" + description: "Tracks and manages ongoing maintenance fee payments and renewal deadlines for granted patents." + actors: "Primary: PCC_ADMIN; Secondary: Director, System" + preconditions: "Patent is Granted; maintenance schedule is defined" + + happy_paths: + - scenario: "PCC_ADMIN processes maintenance fee payment on time" + preconditions: "Maintenance fee is due; budget available" + input_action: "POST /patent/{id}/maintenance_fee with payment_amount, payment_date" + expected_result: "Payment recorded; patent renewal confirmed; next deadline scheduled" + + exception_paths: + - scenario: "Maintenance fee not paid before deadline" + preconditions: "Fee due date has passed" + input_action: "SYSTEM_EVENT: maintenance_fee.overdue" + expected_result: "Patent flagged for lapse; escalation notification sent; grace period window activated" + + business_rules: ["BR-PMS-009", "BR-PMS-013", "BR-PMS-018"] + workflows: ["PMS-WF-501"] + + + - id: "PMS-UC-014" + title: "Withdraw Patent Application" + description: "Allows applicant or PCC_ADMIN to formally withdraw a patent application before final disposition." + actors: "Primary: Applicant; Secondary: PCC_ADMIN" + preconditions: "Application is in an active, non-final state (e.g., Draft, Submitted, Under Review, Needs Revision)" + + happy_paths: + - scenario: "Applicant withdraws application before review begins" + preconditions: "Application status is Submitted or Draft" + input_action: "DELETE /patent/application/{id} with reason={withdrawal_reason}" + expected_result: "Application status changed to Withdrawn; record locked; stakeholders notified" + + exception_paths: + - scenario: "Applicant attempts to withdraw an already-approved application" + preconditions: "Application status is Approved or Filed" + input_action: "DELETE /patent/application/{id}" + expected_result: "Withdrawal rejected with status error; PCC_ADMIN must authorize any late-stage withdrawal" + + business_rules: ["BR-PMS-016", "BR-PMS-018"] + workflows: ["PMS-WF-201"] + + + - id: "PMS-UC-015" + title: "Generate Reports and Analytics Dashboards" + description: "Generates reports and visual dashboards on application pipeline, inventor activity, budget utilization, and patent outcomes." + actors: "Primary: PCC_ADMIN, Director; Secondary: nil" + preconditions: "User has reporting permissions; data exists in the system" + + happy_paths: + - scenario: "PCC_ADMIN generates a monthly application status report" + preconditions: "PCC_ADMIN is logged in" + input_action: "GET /reports/applications?period=monthly&format=pdf" + expected_result: "PDF report generated with application counts by status; download link provided" + + alternate_paths: + - scenario: "Director views live analytics dashboard" + preconditions: "Director is logged in" + input_action: "GET /dashboard/director" + expected_result: "Interactive dashboard displayed with current pipeline, pending reviews, and deadlines" + + exception_paths: + - scenario: "Report generation fails due to data query timeout" + preconditions: "Large dataset being queried" + input_action: "GET /reports/applications?period=annual" + expected_result: "Timeout error returned; user advised to narrow filter or retry" + + business_rules: ["BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-016" + title: "Manage Co-Inventors and Inventor Agreements" + description: "Manages co-inventor records, contribution percentages, and ensures all inventor agreements are signed before submission." + actors: "Primary: Applicant, PCC_ADMIN; Secondary: nil" + preconditions: "Application exists; all co-inventors are registered users or can be invited" + + happy_paths: + - scenario: "Applicant adds co-inventor and system sends agreement for e-signature" + preconditions: "Application is in Draft; co-inventor email is valid" + input_action: "POST /patent/application/{id}/inventors with co_inventor_email, contribution_percentage" + expected_result: "Co-inventor added; agreement email dispatched; application cannot be submitted until agreement is signed" + + exception_paths: + - scenario: "Co-inventor does not sign agreement before submission deadline" + preconditions: "Application ready for submission; co-inventor agreement pending" + input_action: "POST /patent/application/{id}/submit" + expected_result: "Submission blocked with unsigned agreement error; reminder sent to co-inventor" + + business_rules: ["BR-PMS-001", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-017" + title: "Patent Licensing / Tech Transfer Request" + description: "Handles requests, reviews, negotiations, and recordkeeping for licensing or transfer of granted patents." + actors: "Primary: Applicant; Secondary: PCC_ADMIN, Director, Legal" + preconditions: "Patent is Granted or reviewed; licensing policy is in place" + + happy_paths: + - scenario: "Applicant submits licensing request; committee approves and agreement is finalized" + preconditions: "Patent is Granted; licensing policy exists" + input_action: "POST /patent/{id}/licensing_request with proposed_terms, external_party_details" + expected_result: "License record created; IP and financial systems updated; stakeholders notified" + + alternate_paths: + - scenario: "Negotiation involves confidential terms; redacted record created" + preconditions: "Licensing negotiation in progress" + input_action: "POST /patent/{id}/licensing_request with confidential=true" + expected_result: "Redacted record stored; accessible only to authorized roles" + + exception_paths: + - scenario: "Negotiation fails; request archived" + preconditions: "Licensing request submitted; negotiation rounds exhausted" + input_action: "PATCH /patent/{id}/licensing_request/{req_id} with status=Failed" + expected_result: "Request archived; retry option available; no financial updates made" + + business_rules: ["BR-PMS-013", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-018" + title: "Appeal Against Rejection" + description: "Applicant can lodge an appeal with grounds; Director checks and may remand to attorney for action." + actors: "Primary: Applicant; Secondary: Director, PCC_ADMIN" + preconditions: "Application was rejected; appeal period is still valid" + + happy_paths: + - scenario: "Applicant files appeal within time limit; Director upholds and remands for amendment" + preconditions: "Application is Rejected; appeal window is open" + input_action: "POST /patent/application/{id}/appeal with grounds={justification}, supporting_documents" + expected_result: "Appeal logged; Director reviews; decision to remand routes application to attorney" + + alternate_paths: + - scenario: "Director modifies the original decision upon appeal" + preconditions: "Appeal is under review by Director" + input_action: "POST /patent/application/{id}/appeal/{appeal_id}/decision with outcome=Modified" + expected_result: "Application status updated per modified decision; applicant notified" + + exception_paths: + - scenario: "Appeal submitted after the appeal period has expired" + preconditions: "Rejection is recorded; appeal window has closed" + input_action: "POST /patent/application/{id}/appeal" + expected_result: "Appeal rejected with time-expiry error; process closed" + + business_rules: ["BR-PMS-005", "BR-PMS-008", "BR-PMS-015", "BR-PMS-016", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-019" + title: "Search Prior Art" + description: "Enables users to search for and attach prior art references to support novelty and patentability claims." + actors: "Primary: Applicant, Attorney; Secondary: nil" + preconditions: "Application details entered; database resources accessible" + + happy_paths: + - scenario: "Applicant searches prior art and attaches relevant references to the application" + preconditions: "Application is in Draft or Under Review; database is accessible" + input_action: "GET /prior_art/search?keywords={terms}&classifications={cls}&date_range={range}" + expected_result: "Ranked search results returned; selected references attached to application; session logged" + + alternate_paths: + - scenario: "No results found; system suggests keyword refinements" + preconditions: "Search performed with narrow or niche keywords" + input_action: "GET /prior_art/search?keywords={narrow_terms}" + expected_result: "Empty results with keyword expansion suggestions displayed; user can refine and retry" + + exception_paths: + - scenario: "Database query fails due to external service unavailability" + preconditions: "Search initiated; external patent database is down" + input_action: "GET /prior_art/search?keywords={terms}" + expected_result: "Error message displayed; session ends gracefully; retry option shown" + + business_rules: ["BR-PMS-003", "BR-PMS-007", "BR-PMS-012", "BR-PMS-018"] + workflows: [] + + + - id: "PMS-UC-020" + title: "Manage Document Versions" + description: "Allows users to upload, track, and manage different versions of all application documents." + actors: "Primary: Applicant, Attorney, PCC_ADMIN; Secondary: nil" + preconditions: "Application exists in the system; prior document versions may exist" + + happy_paths: + - scenario: "Applicant uploads a new version of the patent abstract; history maintained" + preconditions: "Application is active; applicant has edit access" + input_action: "POST /patent/application/{id}/documents/{doc_id}/versions with new_file" + expected_result: "New version saved; previous version archived; version history updated; stakeholders notified" + + alternate_paths: + - scenario: "User views version history and compares two versions" + preconditions: "At least two document versions exist" + input_action: "GET /patent/application/{id}/documents/{doc_id}/versions?compare=v1,v2" + expected_result: "Version comparison view displayed with tracked changes highlighted" + + exception_paths: + - scenario: "Uploaded document fails virus scan" + preconditions: "New version upload initiated" + input_action: "POST /patent/application/{id}/documents/{doc_id}/versions with infected_file" + expected_result: "Upload blocked; virus scan failure error shown; previous version remains current; notification sent" + + - scenario: "Version conflict detected between simultaneous uploads" + preconditions: "Two users upload a new version at the same time" + input_action: "POST /patent/application/{id}/documents/{doc_id}/versions (concurrent requests)" + expected_result: "Conflict detected; merge process initiated; neither version automatically published until resolved" + + business_rules: ["BR-PMS-014", "BR-PMS-017", "BR-PMS-018"] + workflows: [] diff --git a/FusionIIIT/applications/patent_system/tests/specs/workflows.yaml b/FusionIIIT/applications/patent_system/tests/specs/workflows.yaml new file mode 100644 index 000000000..4a71593d4 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/specs/workflows.yaml @@ -0,0 +1,221 @@ +workflows: + + - id: "PMS-WF-101" + title: "Submit Patent Application" + description: "Structured process for applicants to create, validate, and formally submit new patent applications into the system for review and processing." + trigger: "User clicks 'New Application' or 'Submit Patent Application' button" + actors: ["Applicant", "System", "PCC_ADMIN"] + preconditions: + - "Applicant is logged in and has 'Applicant' role" + - "All necessary documents and details are ready" + - "Required agreements signed" + start_task: "PMS-UC-001 Submit Patent Application" + end_states: ["END-SUBMITTED", "END-VALIDATION_FAILED", "END-DRAFT_WITHDRAWN"] + + e2e_tests: + - scenario: "Authorized applicant submits a complete application end-to-end" + steps: + - "Applicant navigates to application form" + - "System validates user authorization" + - "Applicant enters all required details" + - "Applicant uploads valid supporting documents" + - "System validates completeness and classifies documents" + - "Applicant confirms and submits" + - "System assigns Application ID and notifies PCC_ADMIN" + expected_final_state: "Application status=Submitted; confirmation email sent; PCC_ADMIN notified" + guard_rules: ["BR-PMS-001", "BR-PMS-002", "BR-PMS-010", "BR-PMS-013", "BR-PMS-018"] + + negative_tests: + - scenario: "Unauthorized user attempts to access the application form" + steps: + - "User without 'Applicant' role navigates to application form" + - "System checks authorization" + expected_final_state: "END-VALIDATION_FAILED; access blocked with authorization error" + guard_rules: ["BR-PMS-002"] + + - scenario: "Applicant withdraws the draft before submission" + steps: + - "Applicant fills partial details" + - "Applicant selects 'Withdraw Draft'" + expected_final_state: "END-DRAFT_WITHDRAWN; application deleted; no notifications sent" + + + - id: "PMS-WF-201" + title: "Application Revision and Resubmission" + description: "Structured and time-bound process for applicants to address Director feedback and resubmit applications for a new review cycle." + trigger: "Application status is changed to 'Needs Revision' or 'Rejected' and applicant is notified" + actors: ["Applicant", "System"] + preconditions: + - "Application has status of 'Needs Revision' or 'Rejected'" + - "Applicant has access to edit the application" + start_task: "PMS-UC-004 Revise and Resubmit Application" + end_states: ["END-RESUBMITTED", "END-EXPIRED", "END-WITHDRAWN"] + + e2e_tests: + - scenario: "Applicant revises and resubmits within the 60-day window" + steps: + - "Applicant opens application and reviews Director feedback" + - "System confirms revision is within the 60-day window" + - "Applicant makes revisions and uploads updated documents" + - "Applicant submits the revised application" + expected_final_state: "END-RESUBMITTED; application enters new review cycle; version history maintained" + guard_rules: ["BR-PMS-016"] + + negative_tests: + - scenario: "Applicant misses the 60-day resubmission deadline" + steps: + - "Applicant opens application after 60 days have elapsed" + - "System checks revision window" + expected_final_state: "END-EXPIRED; status changed to Expired; applicant notified" + guard_rules: ["BR-PMS-016"] + + - scenario: "Applicant withdraws application during the revision phase" + steps: + - "Applicant opens application for revision" + - "Applicant selects 'Withdraw Application'" + - "System processes withdrawal via PMS-UC-014" + expected_final_state: "END-WITHDRAWN; application locked and archived" + guard_rules: ["BR-PMS-016"] + + + - id: "PMS-WF-301" + title: "Budget Approval and Financial Tracking" + description: "Ensures all patent-related expenditures are reviewed, approved by the proper authority, and tracked against the available budget." + trigger: "A cost estimate is attached to an application, or it reaches a stage requiring funding (e.g., official filing)" + actors: ["PCC_ADMIN", "Director"] + preconditions: + - "Application status is 'Approved'" + - "Cost estimates for fees are available" + start_task: "PMS-UC-008 Manage Budgets and Financial Approval" + end_states: ["END-BUDGET_APPROVED", "END-APPROVAL_DENIED", "END-ESCALATED_TO_DIRECTOR"] + + e2e_tests: + - scenario: "Budget within PCC_ADMIN threshold is approved and financial tracking initiated" + steps: + - "Cost estimate is submitted with application" + - "PCC_ADMIN reviews budget request" + - "Amount is within PCC_ADMIN approval authority" + - "PCC_ADMIN approves the budget" + - "Financial tracking record created" + expected_final_state: "END-BUDGET_APPROVED; financial record updated; stakeholders notified" + guard_rules: ["BR-PMS-008", "BR-PMS-009"] + + negative_tests: + - scenario: "Budget exceeds PCC_ADMIN authority; escalated to Director who denies it" + steps: + - "Cost estimate exceeds PCC_ADMIN approval threshold" + - "Request auto-escalated to Director" + - "Director reviews and denies the request" + expected_final_state: "END-APPROVAL_DENIED; application on financial hold; PCC_ADMIN notified" + guard_rules: ["BR-PMS-008", "BR-PMS-009"] + + + - id: "PMS-WF-401" + title: "Director Assignment and Routing" + description: "Routes newly submitted applications to appropriate Directors based on expertise and workload balancing." + trigger: "Application status changes to 'Submitted' or PCC_ADMIN selects from new applications queue" + actors: ["PCC_ADMIN", "System", "Director"] + preconditions: + - "Application exists with status=Submitted" + - "Directors have defined expertise areas and current workloads" + start_task: "PMS-UC-002 Assign Application to Director" + end_states: ["END-ASSIGNED", "END-ASSIGNMENT_FAILED"] + + e2e_tests: + - scenario: "System auto-assigns application to best-matched Director and initiates review" + steps: + - "Application status changes to Submitted" + - "System retrieves application and available Director list" + - "Matching logic applies expertise and workload rules" + - "Application assigned to Director" + - "Director notified; review timeline started" + expected_final_state: "END-ASSIGNED; application status=Under Review; Director receives notification" + guard_rules: ["BR-PMS-003", "BR-PMS-004", "BR-PMS-011", "BR-PMS-012", "BR-PMS-015"] + + negative_tests: + - scenario: "No Director is available; assignment fails and retry is scheduled" + steps: + - "Application is Submitted" + - "System finds no Director with available capacity" + expected_final_state: "END-ASSIGNMENT_FAILED; application remains Submitted; retry scheduled; PCC_ADMIN alerted" + guard_rules: ["BR-PMS-011"] + + + - id: "PMS-WF-501" + title: "Post-Grant Tracking and Maintenance" + description: "Tracks maintenance fee schedules, renewal deadlines, and licensing activity for granted patents." + trigger: "Patent reaches Granted status; maintenance schedule is activated" + actors: ["PCC_ADMIN", "System", "Director"] + preconditions: + - "Patent has been granted" + - "Maintenance fee schedule is configured" + start_task: "PMS-UC-013 Manage Maintenance Fees and Renewals" + end_states: ["END-FEE_PAID", "END-PATENT_LAPSED", "END-GRACE_PERIOD_ACTIVE"] + + e2e_tests: + - scenario: "PCC_ADMIN pays maintenance fee on time; patent renewed and next deadline scheduled" + steps: + - "System triggers upcoming fee reminder" + - "PCC_ADMIN processes payment" + - "Payment confirmed and recorded" + - "Next renewal deadline scheduled" + expected_final_state: "END-FEE_PAID; patent status remains Granted; next deadline set" + guard_rules: ["BR-PMS-009", "BR-PMS-013"] + + negative_tests: + - scenario: "Maintenance fee missed; patent enters lapse risk with grace period activated" + steps: + - "System detects fee deadline has passed" + - "Escalation notification sent to PCC_ADMIN and Director" + - "Grace period window opened" + expected_final_state: "END-GRACE_PERIOD_ACTIVE; patent flagged for lapse risk; 30-day grace window active" + guard_rules: ["BR-PMS-013"] + + + - id: "PMS-WF-601" + title: "External Filing and Office Communication" + description: "Manages the official filing of applications with the patent office and subsequent communication including Office Action responses." + trigger: "Application is Approved with budget cleared and legal assessment complete" + actors: ["Attorney", "PCC_ADMIN", "System"] + preconditions: + - "Application status is Approved" + - "Budget approval confirmed" + - "Legal assessment completed" + start_task: "PMS-UC-009 File with Patent Office / Log Official Filing" + end_states: ["END-FILED", "END-FILING_FAILED", "END-OFFICE_ACTION_RESOLVED"] + + e2e_tests: + - scenario: "Attorney files application and receives confirmation; deadline tracking begins" + steps: + - "Attorney prepares filing documents" + - "Attorney submits to patent office" + - "Patent office issues confirmation with reference number" + - "Attorney logs filing in system with date and reference" + - "System activates deadline tracking for Office Actions" + expected_final_state: "END-FILED; application status=Filed; filing date recorded; deadlines active" + guard_rules: ["BR-PMS-009", "BR-PMS-013", "BR-PMS-018"] + + - scenario: "Office Action received; attorney responds within deadline; action closed" + steps: + - "Office Action received and logged in system" + - "System notifies attorney and sets response deadline" + - "Attorney prepares and submits response with amendments" + - "Office Action marked as resolved" + expected_final_state: "END-OFFICE_ACTION_RESOLVED; response filed; application progresses" + guard_rules: ["BR-PMS-005", "BR-PMS-012"] + + negative_tests: + - scenario: "Filing rejected by patent office due to document error" + steps: + - "Attorney submits documents to patent office" + - "Patent office rejects due to format/content error" + - "Attorney logs the rejection in system" + expected_final_state: "END-FILING_FAILED; error logged; attorney notified to correct and refile" + guard_rules: ["BR-PMS-013"] + + - scenario: "Attorney fails to respond to Office Action before deadline" + steps: + - "Office Action deadline passes without response" + - "System triggers deadline-missed escalation" + expected_final_state: "Application flagged at-risk; escalation sent to PCC_ADMIN and Director" + guard_rules: ["BR-PMS-012", "BR-PMS-015"] diff --git a/FusionIIIT/applications/patent_system/tests/test_business_rules.py b/FusionIIIT/applications/patent_system/tests/test_business_rules.py new file mode 100644 index 000000000..98bc0dc95 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/test_business_rules.py @@ -0,0 +1,545 @@ +import json +import unittest +from datetime import timedelta + +from django.utils.timezone import now +from django.core.files.uploadedfile import SimpleUploadedFile + +from applications.patent_system.models import ( + Application, + ApplicationStatus, + AttorneyAssignment, + BudgetDecision, + FilingRecord, + PatentabilityAssessment, + CommunicationLog, +) + +from .base import BRTestBase + + +class _PatentBRFlowMixin: + """Shared setup helpers for BR tests (real API calls + real DB state).""" + + def _submit_pending_consent(self, *, title="BR Test Patent", inventor_shares=None): + self.login_as_applicant() + payload = self.make_submit_payload(title=title, inventor_shares=inventor_shares) + resp, app_id = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 201, msg=getattr(resp, "content", b"")[:500]) + app = Application.objects.get(id=app_id) + self.assertEqual(app.status, ApplicationStatus.PENDING_INVENTOR_CONSENT) + return app + + def _consent_all(self, app: Application): + self.login_as_applicant() + self.api_post(self.API_PREFIX + f"applicant/applications/{app.id}/consent/", {}, expected_status=200) + self.login_as_coinventor() + self.api_post(self.API_PREFIX + f"applicant/applications/{app.id}/consent/", {}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.SUBMITTED) + return app + + def _submit_submitted(self, *, title="BR Test Patent", inventor_shares=None): + return self._consent_all(self._submit_pending_consent(title=title, inventor_shares=inventor_shares)) + + def _pcc_review(self, app: Application, *, comments="Reviewed"): + self.login_as_pcc_admin() + self.api_post( + self.API_PREFIX + f"pccAdmin/applications/new/review/{app.id}/", + {"comments": comments}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.REVIEWED) + return app + + def _pcc_forward(self, app: Application, *, comments="Forwarded"): + self.login_as_pcc_admin() + self.api_post( + self.API_PREFIX + f"pccAdmin/applications/new/forward/{app.id}/", + {"comments": comments}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.FORWARDED) + return app + + def _director_accept(self, app: Application, *, feedback="Approved"): + self.login_as_director() + r = self.api_post( + self.API_PREFIX + "director/application/accept", + {"application_id": app.id, "comments": feedback}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.APPROVED) + return r, app + + def _advance_to_search_report_generated(self, app: Application): + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/ongoing/changeStatus/{app.id}/" + for st in [ + ApplicationStatus.PATENTABILITY_CHECK_STARTED, + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED, + ApplicationStatus.SEARCH_REPORT_GENERATED, + ]: + self.api_post(url, {"next_status": st}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.SEARCH_REPORT_GENERATED) + return app + + +class TestBR_PMS_001(_PatentBRFlowMixin, BRTestBase): + + def test_valid_submission_with_all_required_fields(self): + self._test_id = "BR-PMS-001-V-01"; self._br_id = "BR-PMS-001" + self._scenario = "Submit application with complete payload" + self._input_action = "POST /patentsystem/applicant/applications/submit/" + self._expected_result = "201; Application created" + + self._submit_pending_consent(title="BR001") + + def test_invalid_missing_required_field(self): + self._test_id = "BR-PMS-001-I-01"; self._br_id = "BR-PMS-001" + self._scenario = "Submit application missing required field" + self._expected_result = "400; Missing required field" + + self.login_as_applicant() + payload = self.make_submit_payload(title="BR001-missing") + payload.pop("title") + resp, _ = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 400) + + +class TestBR_PMS_002(_PatentBRFlowMixin, BRTestBase): + + def test_valid_authenticated_applicant_can_submit(self): + self._test_id = "BR-PMS-002-V-01"; self._br_id = "BR-PMS-002" + self._scenario = "Authenticated applicant submits" + self._expected_result = "201" + self._submit_pending_consent(title="BR002") + + def test_invalid_unauthenticated_cannot_submit(self): + self._test_id = "BR-PMS-002-I-01"; self._br_id = "BR-PMS-002" + self._scenario = "Unauthenticated user submits" + self._expected_result = "401" + self.logout() + payload = self.make_submit_payload(title="BR002-unauth") + resp, _ = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 401) + + +class TestBR_PMS_003(_PatentBRFlowMixin, BRTestBase): + + def test_valid_director_not_inventor_can_approve(self): + self._test_id = "BR-PMS-003-V-01"; self._br_id = "BR-PMS-003" + app = self._submit_submitted(title="BR003") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + + def test_invalid_director_inventor_conflict_blocked(self): + self._test_id = "BR-PMS-003-I-01"; self._br_id = "BR-PMS-003" + # Make director an inventor -> director_review should 409 + app = self._submit_pending_consent( + title="BR003-conflict", + inventor_shares=[(self.applicant_user, 50), (self.director_user, 50)], + ) + self._consent_all(app) + self._pcc_review(app) + self._pcc_forward(app) + + self.login_as_director() + self.api_post( + self.API_PREFIX + "director/application/accept", + {"application_id": app.id, "comments": "ok"}, + expected_status=409, + ) + + +class TestBR_PMS_004(_PatentBRFlowMixin, BRTestBase): + + def test_valid_attorney_assignment_only_after_approval(self): + self._test_id = "BR-PMS-004-V-01"; self._br_id = "BR-PMS-004" + app = self._submit_submitted(title="BR004") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/attorney/" + r = self.client.post(url, {"attorney_name": "Adv A"}, format="multipart") + self.assertEqual(r.status_code, 201) + self.assertTrue(AttorneyAssignment.objects.filter(application=app).exists()) + + def test_invalid_attorney_assignment_before_approval_blocked(self): + self._test_id = "BR-PMS-004-I-01"; self._br_id = "BR-PMS-004" + app = self._submit_submitted(title="BR004-pre") + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/attorney/" + r = self.client.post(url, {"attorney_name": "Adv A"}, format="multipart") + self.assertEqual(r.status_code, 400) + + +class TestBR_PMS_005(_PatentBRFlowMixin, BRTestBase): + + def test_valid_reject_requires_justification_min_50(self): + self._test_id = "BR-PMS-005-V-01"; self._br_id = "BR-PMS-005" + app = self._submit_submitted(title="BR005") + self._pcc_review(app) + self._pcc_forward(app) + + self.login_as_director() + self.api_post( + self.API_PREFIX + "director/application/reject", + {"application_id": app.id, "decision": "Reject", "comments": "a" * 60}, + expected_status=200, + ) + + def test_invalid_reject_with_short_feedback_blocked(self): + self._test_id = "BR-PMS-005-I-01"; self._br_id = "BR-PMS-005" + app = self._submit_submitted(title="BR005-short") + self._pcc_review(app) + self._pcc_forward(app) + + self.login_as_director() + self.api_post( + self.API_PREFIX + "director/application/reject", + {"application_id": app.id, "decision": "Reject", "comments": "short"}, + expected_status=400, + ) + + +class TestBR_PMS_006(_PatentBRFlowMixin, BRTestBase): + + def test_valid_inventor_can_upload_document(self): + self._test_id = "BR-PMS-006-V-01"; self._br_id = "BR-PMS-006" + app = self._submit_pending_consent(title="BR006") + + self.login_as_applicant() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/documents/" + f = SimpleUploadedFile("doc.pdf", b"%PDF-1.4", content_type="application/pdf") + r = self.client.post(url, {"document_type": "POC", "title": "POC", "file": f}, format="multipart") + self.assertEqual(r.status_code, 201) + + def test_invalid_non_inventor_cannot_upload_document(self): + self._test_id = "BR-PMS-006-I-01"; self._br_id = "BR-PMS-006" + app = self._submit_pending_consent(title="BR006-no") + + self.login_as_outsider() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/documents/" + f = SimpleUploadedFile("doc.pdf", b"%PDF-1.4", content_type="application/pdf") + r = self.client.post(url, {"document_type": "POC", "title": "POC", "file": f}, format="multipart") + self.assertEqual(r.status_code, 403) + + +class TestBR_PMS_007(_PatentBRFlowMixin, BRTestBase): + + def test_valid_pcc_admin_can_assign_attorney(self): + self._test_id = "BR-PMS-007-V-01"; self._br_id = "BR-PMS-007" + app = self._submit_submitted(title="BR007") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/attorney/" + r = self.client.post(url, {"attorney_name": "Adv Panel"}, format="multipart") + self.assertEqual(r.status_code, 201) + + def test_invalid_non_pcc_cannot_assign_attorney(self): + self._test_id = "BR-PMS-007-I-01"; self._br_id = "BR-PMS-007" + app = self._submit_submitted(title="BR007-non") + app.status = ApplicationStatus.APPROVED + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/attorney/" + r = self.client.post(url, {"attorney_name": "Adv"}, format="multipart") + self.assertEqual(r.status_code, 403) + + +class TestBR_PMS_008(_PatentBRFlowMixin, BRTestBase): + + def test_valid_budget_below_threshold_auto_approved_by_pcc(self): + self._test_id = "BR-PMS-008-V-01"; self._br_id = "BR-PMS-008" + app = self._submit_submitted(title="BR008") + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/budget/" + r = self.api_post(url, {"filing_cost": 1000, "attorney_fees": 0, "administrative_cost": 0}, expected_status=200) + self.assertEqual(r.json().get("decision"), BudgetDecision.APPROVED_PCC) + + def test_invalid_non_pcc_cannot_set_budget(self): + self._test_id = "BR-PMS-008-I-01"; self._br_id = "BR-PMS-008" + app = self._submit_submitted(title="BR008-non") + self.login_as_applicant() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/budget/" + self.api_post(url, {"filing_cost": 1}, expected_status=403) + + +class TestBR_PMS_009(_PatentBRFlowMixin, BRTestBase): + + def test_valid_status_changes_follow_allowed_transitions(self): + self._test_id = "BR-PMS-009-V-01"; self._br_id = "BR-PMS-009" + app = self._submit_submitted(title="BR009") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/ongoing/changeStatus/{app.id}/" + self.api_post(url, {"next_status": ApplicationStatus.PATENTABILITY_CHECK_STARTED}, expected_status=200) + + def test_invalid_transition_rejected(self): + self._test_id = "BR-PMS-009-I-01"; self._br_id = "BR-PMS-009" + app = self._submit_submitted(title="BR009-bad") + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/ongoing/changeStatus/{app.id}/" + # From Submitted directly to Patent Filed is not allowed + self.api_post(url, {"next_status": ApplicationStatus.PATENT_FILED}, expected_status=400) + + +class TestBR_PMS_010(_PatentBRFlowMixin, BRTestBase): + + def test_valid_token_generated_on_director_approval(self): + self._test_id = "BR-PMS-010-V-01"; self._br_id = "BR-PMS-010" + app = self._submit_submitted(title="BR010") + self._pcc_review(app) + self._pcc_forward(app) + + _, app = self._director_accept(app) + self.assertTrue(app.token_no) + self.assertIn("IIITDMJ/", app.token_no) + + @unittest.skip("File type validation is not enforced on ApplicationDocument uploads in current implementation.") + def test_invalid_file_format_rejected(self): + self._test_id = "BR-PMS-010-I-01"; self._br_id = "BR-PMS-010" + + +class TestBR_PMS_011(_PatentBRFlowMixin, BRTestBase): + + def test_valid_unread_count_increments_after_submit(self): + self._test_id = "BR-PMS-011-V-01"; self._br_id = "BR-PMS-011" + self._submit_pending_consent(title="BR011") + + self.login_as_coinventor() + r = self.api_get(self.API_PREFIX + "notifications/unread-count/", expected_status=200) + self.assertGreaterEqual(r.json().get("unread_count", 0), 1) + + def test_invalid_unauthenticated_cannot_fetch_notifications(self): + self._test_id = "BR-PMS-011-I-01"; self._br_id = "BR-PMS-011" + self.logout() + self.api_get(self.API_PREFIX + "notifications/", expected_status=401) + + +@unittest.skip("Dedicated director assignment endpoint is not exposed; forwarding represents director-queueing in this implementation.") +class TestBR_PMS_012(BRTestBase): + def test_placeholder(self): + self._test_id = "BR-PMS-012-V-01"; self._br_id = "BR-PMS-012" + + +class TestBR_PMS_013(_PatentBRFlowMixin, BRTestBase): + + def test_valid_pcc_review_requires_all_inventor_consents(self): + self._test_id = "BR-PMS-013-V-01"; self._br_id = "BR-PMS-013" + app = self._submit_pending_consent(title="BR013") + self._consent_all(app) + self._pcc_review(app) + + def test_invalid_pcc_review_blocked_if_missing_consent(self): + self._test_id = "BR-PMS-013-I-01"; self._br_id = "BR-PMS-013" + app = self._submit_pending_consent(title="BR013-miss") + # only primary applicant consents + self.login_as_applicant() + self.api_post(self.API_PREFIX + f"applicant/applications/{app.id}/consent/", {}, expected_status=200) + + self.login_as_pcc_admin() + self.api_post( + self.API_PREFIX + f"pccAdmin/applications/new/review/{app.id}/", + {"comments": "Reviewed"}, + expected_status=400, + ) + + +class TestBR_PMS_014(_PatentBRFlowMixin, BRTestBase): + + def test_valid_patentability_assessment_recorded(self): + self._test_id = "BR-PMS-014-V-01"; self._br_id = "BR-PMS-014" + app = self._submit_submitted(title="BR014") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/assessment/" + r = self.client.post( + url, + { + "recommendation": "File Patent", + "opinion_summary": "This opinion summary is long enough.", + "novelty_score": 80, + "non_obviousness_score": 70, + "utility_score": 90, + "search_completeness": 95, + }, + format="multipart", + ) + self.assertEqual(r.status_code, 201) + self.assertTrue(PatentabilityAssessment.objects.filter(application=app).exists()) + + def test_invalid_assessment_recommendation_rejected(self): + self._test_id = "BR-PMS-014-I-01"; self._br_id = "BR-PMS-014" + app = self._submit_submitted(title="BR014-bad") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/assessment/" + r = self.client.post( + url, + { + "recommendation": "Maybe", + "opinion_summary": "This opinion summary is long enough.", + "novelty_score": 80, + "non_obviousness_score": 70, + "utility_score": 90, + "search_completeness": 95, + }, + format="multipart", + ) + self.assertEqual(r.status_code, 400) + + +@unittest.skip("BR-PMS-015 priority logic is not exposed via an API endpoint in current implementation.") +class TestBR_PMS_015(BRTestBase): + def test_placeholder(self): + self._test_id = "BR-PMS-015-V-01"; self._br_id = "BR-PMS-015" + + +class TestBR_PMS_016(_PatentBRFlowMixin, BRTestBase): + + def test_valid_resubmit_within_deadline(self): + self._test_id = "BR-PMS-016-V-01"; self._br_id = "BR-PMS-016" + app = self._submit_submitted(title="BR016") + self.login_as_pcc_admin() + self.api_post( + self.API_PREFIX + f"pccAdmin/applications/new/requestModification/{app.id}/", + {"comments": "Please revise."}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.NEEDS_REVISION) + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/resubmit/{app.id}/" + r = self.client.post(url, {"json_data": json.dumps({"title": "BR016-new"})}, format="multipart") + self.assertEqual(r.status_code, 200) + + def test_invalid_resubmit_after_deadline_expires(self): + self._test_id = "BR-PMS-016-I-01"; self._br_id = "BR-PMS-016" + app = self._submit_submitted(title="BR016-exp") + app.status = ApplicationStatus.NEEDS_REVISION + app.resubmission_deadline = now() - timedelta(days=1) + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/resubmit/{app.id}/" + r = self.client.post(url, {"json_data": json.dumps({"title": "late"})}, format="multipart") + self.assertEqual(r.status_code, 400) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.EXPIRED) + + +class TestBR_PMS_017(_PatentBRFlowMixin, BRTestBase): + + def test_valid_international_filing_with_justification(self): + self._test_id = "BR-PMS-017-V-01"; self._br_id = "BR-PMS-017" + app = self._submit_submitted(title="BR017") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + self._advance_to_search_report_generated(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/filing/" + r = self.client.post( + url, + { + "filing_office": "USPTO", + "jurisdiction": "USA", + "external_filing_id": "US-123", + "international_filing_justification": "Business need.", + }, + format="multipart", + ) + self.assertEqual(r.status_code, 201) + self.assertTrue(FilingRecord.objects.filter(application=app).exists()) + + def test_invalid_international_filing_missing_justification(self): + self._test_id = "BR-PMS-017-I-01"; self._br_id = "BR-PMS-017" + app = self._submit_submitted(title="BR017-no") + self._pcc_review(app) + self._pcc_forward(app) + self._director_accept(app) + self._advance_to_search_report_generated(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/filing/" + r = self.client.post( + url, + {"filing_office": "USPTO", "jurisdiction": "USA", "external_filing_id": "US-124"}, + format="multipart", + ) + self.assertEqual(r.status_code, 400) + + +class TestBR_PMS_018(_PatentBRFlowMixin, BRTestBase): + + def test_valid_audit_logs_created_for_actions(self): + self._test_id = "BR-PMS-018-V-01"; self._br_id = "BR-PMS-018" + app = self._submit_submitted(title="BR018") + self._pcc_review(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/audit/" + r = self.api_get(url, expected_status=200) + self.assertGreaterEqual(len(r.json()), 1) + + def test_invalid_non_pcc_cannot_view_audit_logs(self): + self._test_id = "BR-PMS-018-I-01"; self._br_id = "BR-PMS-018" + app = self._submit_submitted(title="BR018-non") + self.login_as_applicant() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/audit/" + self.api_get(url, expected_status=403) + + +class TestBR_PMS_019(_PatentBRFlowMixin, BRTestBase): + + def test_valid_pcc_admin_can_log_communication(self): + self._test_id = "BR-PMS-019-V-01"; self._br_id = "BR-PMS-019" + app = self._submit_submitted(title="BR019") + self.login_as_pcc_admin() + + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/communications/" + r = self.client.post( + url, + { + "direction": "Outgoing", + "subject": "Attorney update", + "body": "Details.", + "confidentiality_level": "Confidential", + }, + format="multipart", + ) + self.assertEqual(r.status_code, 201) + self.assertTrue(CommunicationLog.objects.filter(application=app).exists()) + + def test_invalid_non_pcc_cannot_log_communication(self): + self._test_id = "BR-PMS-019-I-01"; self._br_id = "BR-PMS-019" + app = self._submit_submitted(title="BR019-non") + self.login_as_applicant() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/communications/" + r = self.client.post(url, {"direction": "Outgoing", "subject": "x"}, format="multipart") + self.assertEqual(r.status_code, 403) diff --git a/FusionIIIT/applications/patent_system/tests/test_module.py b/FusionIIIT/applications/patent_system/tests/test_module.py new file mode 100644 index 000000000..0b1038604 --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/test_module.py @@ -0,0 +1,118 @@ +""" +Patent Management System — Tests +""" + +from django.test import TestCase, TransactionTestCase +from django.contrib.auth.models import User +from rest_framework.test import APIClient + +from applications.patent_system.models import ( + Applicant, Application, ApplicationStatus, DecisionStatus, + Inventor, CommunicationLog, Budget, AuditLog, + AttorneyAssignment, PatentabilityAssessment, FilingRecord, + PatentabilityRecommendation, +) +from applications.patent_system import services + + +class PatentModelTests(TestCase): + """Basic model creation tests.""" + + def setUp(self): + self.user = User.objects.create_user("testuser", "test@iiitdmj.ac.in", "pass") + self.applicant = Applicant.objects.create( + user=self.user, name="Test User", email="test@iiitdmj.ac.in" + ) + self.application = Application.objects.create( + title="Test Patent", + primary_applicant=self.applicant, + status=ApplicationStatus.DRAFT, + ) + + def test_application_created(self): + self.assertEqual(self.application.status, ApplicationStatus.DRAFT) + + def test_inventor_association(self): + inv = Inventor.objects.create( + applicant=self.applicant, application=self.application, percentage_share=100 + ) + self.assertEqual(inv.percentage_share, 100) + + def test_budget_total_calculation(self): + b = Budget.objects.create( + application=self.application, + filing_cost=1000, + attorney_fees=2000, + administrative_cost=500, + ) + self.assertEqual(b.total_cost, 3500) + + def test_audit_log_creation(self): + AuditLog.objects.create( + application=self.application, + user=self.user, + action="Test action", + ) + self.assertEqual(AuditLog.objects.count(), 1) + + def test_attorney_assignment_creation(self): + """UC-006: Test that AttorneyAssignment model can be created.""" + assignment = AttorneyAssignment.objects.create( + application=self.application, + attorney_name="Adv. Sharma", + attorney_email="sharma@lawfirm.com", + attorney_firm="Sharma & Associates", + specialization="Patent Law", + assigned_by=self.user, + ) + self.assertEqual(assignment.attorney_name, "Adv. Sharma") + self.assertTrue(assignment.is_active) + + def test_patentability_assessment_creation(self): + """UC-007 / BR-PMS-014: Test PatentabilityAssessment model.""" + assessment = PatentabilityAssessment.objects.create( + application=self.application, + assessed_by_attorney="Adv. Sharma", + novelty_score=85, + non_obviousness_score=70, + utility_score=90, + search_completeness=95, + recommendation=PatentabilityRecommendation.FILE_PATENT, + opinion_summary="The invention shows significant novelty and utility.", + recorded_by=self.user, + ) + self.assertEqual(assessment.recommendation, PatentabilityRecommendation.FILE_PATENT) + + def test_filing_record_creation(self): + """UC-009 / WF-601: Test FilingRecord model.""" + filing = FilingRecord.objects.create( + application=self.application, + filing_office="Indian Patent Office", + jurisdiction="India", + external_filing_id="IPO/2026/001234", + filed_by=self.user, + ) + self.assertEqual(filing.external_filing_id, "IPO/2026/001234") + + def test_communication_log_confidentiality(self): + """BR-PMS-019: Test that CommunicationLog supports confidentiality_level.""" + log = CommunicationLog.objects.create( + application=self.application, + logged_by=self.user, + direction="Outgoing", + subject="Initial contact with attorney", + confidentiality_level="Attorney-Client Privileged", + ) + self.assertEqual(log.confidentiality_level, "Attorney-Client Privileged") + + +class StatusTransitionTests(TestCase): + """Test valid/invalid status transitions.""" + + def test_valid_transition(self): + # Should not raise + services._validate_transition(ApplicationStatus.DRAFT, ApplicationStatus.SUBMITTED) + + def test_invalid_transition(self): + with self.assertRaises(services.ValidationError): + services._validate_transition(ApplicationStatus.DRAFT, ApplicationStatus.APPROVED) diff --git a/FusionIIIT/applications/patent_system/tests/test_use_cases.py b/FusionIIIT/applications/patent_system/tests/test_use_cases.py new file mode 100644 index 000000000..3517814bf --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/test_use_cases.py @@ -0,0 +1,887 @@ +import json +import unittest +from datetime import timedelta + +from django.utils.timezone import now + +from django.core.files.uploadedfile import SimpleUploadedFile + +from applications.patent_system.models import ( + Application, + ApplicationStatus, + AttorneyAssignment, + FilingRecord, + PatentabilityAssessment, +) + +from .base import UCTestBase + + +class _PatentFlowMixin: + """Helpers to put an application into the preconditions required by later UCs.""" + + def _submit_application_pending_consent(self, *, title="Test Patent"): + self.login_as_applicant() + payload = self.make_submit_payload(title=title) + resp, app_id = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 201, msg=getattr(resp, "content", b"")[:500]) + app = Application.objects.get(id=app_id) + self.assertEqual(app.status, ApplicationStatus.PENDING_INVENTOR_CONSENT) + return app + + def _give_all_inventor_consents(self, app: Application): + # Primary applicant consent + self.login_as_applicant() + r1 = self.post_give_consent(app.id) + self.assertEqual(r1.status_code, 200, msg=getattr(r1, "content", b"")[:500]) + + # Co-inventor consent + self.login_as_coinventor() + r2 = self.post_give_consent(app.id) + self.assertEqual(r2.status_code, 200, msg=getattr(r2, "content", b"")[:500]) + + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.SUBMITTED) + return app + + def _submit_application_submitted(self, *, title="Test Patent"): + app = self._submit_application_pending_consent(title=title) + return self._give_all_inventor_consents(app) + + def _pcc_review_and_forward(self, app: Application): + self.login_as_pcc_admin() + review_url = self.API_PREFIX + f"pccAdmin/applications/new/review/{app.id}/" + r = self.api_post(review_url, {"comments": "Reviewed."}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.REVIEWED) + + forward_url = self.API_PREFIX + f"pccAdmin/applications/new/forward/{app.id}/" + r2 = self.api_post(forward_url, {"comments": "Forwarded to director."}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.FORWARDED) + return app + + def _director_approve(self, app: Application): + self.login_as_director() + url = self.API_PREFIX + "director/application/accept" + r = self.api_post( + url, + { + "application_id": app.id, + "comments": "Approved." + }, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.APPROVED) + return app + + def _advance_to_search_report_generated(self, app: Application): + # Approved -> Patentability Check Started -> Completed -> Search Report Generated + self.login_as_pcc_admin() + change_url = self.API_PREFIX + f"pccAdmin/applications/ongoing/changeStatus/{app.id}/" + for st in [ + ApplicationStatus.PATENTABILITY_CHECK_STARTED, + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED, + ApplicationStatus.SEARCH_REPORT_GENERATED, + ]: + self.api_post(change_url, {"next_status": st}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.SEARCH_REPORT_GENERATED) + return app + + +# ========================================================================= +# UC-001: Submit Patent Application +# ========================================================================= +class TestPMS_UC_001(_PatentFlowMixin, UCTestBase): + + def test_hp01_submit_complete_application_creates_pending_consent(self): + self._test_id = "PMS-UC-001-HP-01"; self._uc_id = "PMS-UC-001" + self._test_category = "Happy Path" + self._scenario = "Applicant submits complete application" + self._input_action = "POST /patentsystem/applicant/applications/submit/" + self._expected_result = "201; Application created with status=Pending Inventor Consent" + + app = self._submit_application_pending_consent(title="UC001") + self._record_result( + self._test_id, + self._scenario, + "Pass", + actual=f"Created application_id={app.id}, status={app.status}", + evidence="", + ) + + def test_ap01_all_consents_auto_submits_application(self): + self._test_id = "PMS-UC-001-AP-01"; self._uc_id = "PMS-UC-001" + self._test_category = "Alternate Paths" + self._scenario = "All inventors give consent; system auto-submits" + self._input_action = "POST /.../consent/ by each inventor" + self._expected_result = "status transitions to Submitted" + + app = self._submit_application_pending_consent(title="UC001-consent") + app = self._give_all_inventor_consents(app) + self._record_result( + self._test_id, + self._scenario, + "Pass", + actual=f"status={app.status}", + evidence="", + ) + + def test_ex01_unauthenticated_user_cannot_submit(self): + self._test_id = "PMS-UC-001-EX-01"; self._uc_id = "PMS-UC-001" + self._test_category = "Exception" + self._scenario = "Unauthenticated user attempts submission" + self._input_action = "POST /patentsystem/applicant/applications/submit/" + self._expected_result = "401 Unauthorized" + + self.logout() + payload = self.make_submit_payload(title="UC001-unauth") + resp, _ = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 401) + + +# ========================================================================= +# UC-002: Assign Application to Director (mapped to PCC forward + director queue) +# ========================================================================= +class TestPMS_UC_002(_PatentFlowMixin, UCTestBase): + + def test_hp01_pcc_forwards_to_director_queue(self): + self._test_id = "PMS-UC-002-HP-01"; self._uc_id = "PMS-UC-002" + self._test_category = "Happy Path" + self._scenario = "PCC Admin forwards reviewed application to Director" + self._input_action = "POST /patentsystem/pccAdmin/applications/new/forward/{id}/" + self._expected_result = "200; status becomes Forwarded for Director's Review" + + app = self._submit_application_submitted(title="UC002") + app = self._pcc_review_and_forward(app) + self._record_result(self._test_id, self._scenario, "Pass", actual=f"status={app.status}", evidence="") + + def test_ap01_forward_rejects_overlong_comment(self): + self._test_id = "PMS-UC-002-AP-01"; self._uc_id = "PMS-UC-002" + self._test_category = "Alternate Paths" + self._scenario = "Forwarding with comment >1000 chars" + self._expected_result = "400 Validation error" + + app = self._submit_application_submitted(title="UC002-long") + self.login_as_pcc_admin() + forward_url = self.API_PREFIX + f"pccAdmin/applications/new/forward/{app.id}/" + r = self.api_post(forward_url, {"comments": "x" * 1001}, expected_status=400) + self._record_result(self._test_id, self._scenario, "Pass", actual=f"status={r.status_code}", evidence=r.content[:500].decode(errors="ignore")) + + def test_ex01_non_pcc_cannot_forward(self): + self._test_id = "PMS-UC-002-EX-01"; self._uc_id = "PMS-UC-002" + self._test_category = "Exception" + self._scenario = "Non-PCC user tries to forward" + self._expected_result = "403 Forbidden" + + app = self._submit_application_submitted(title="UC002-nonpcc") + self.login_as_outsider() + forward_url = self.API_PREFIX + f"pccAdmin/applications/new/forward/{app.id}/" + r = self.api_post(forward_url, {"comments": "No."}, expected_status=403) + self._record_result(self._test_id, self._scenario, "Pass", actual=f"status={r.status_code}", evidence="") + + +# ========================================================================= +# UC-003: Review Patent Application (Director) +# ========================================================================= +class TestPMS_UC_003(_PatentFlowMixin, UCTestBase): + + def test_hp01_director_approves_forwarded_application(self): + self._test_id = "PMS-UC-003-HP-01"; self._uc_id = "PMS-UC-003" + self._test_category = "Happy Path" + self._scenario = "Director approves" + self._expected_result = "200; status=Approved" + + app = self._submit_application_submitted(title="UC003") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + self._record_result(self._test_id, self._scenario, "Pass", actual=f"status={app.status}", evidence="") + + def test_ap01_director_requests_revision_with_long_feedback(self): + self._test_id = "PMS-UC-003-AP-01"; self._uc_id = "PMS-UC-003" + self._test_category = "Alternate Paths" + self._scenario = "Director requests revision" + self._expected_result = "200; status=Needs Revision" + + app = self._submit_application_submitted(title="UC003-rev") + app = self._pcc_review_and_forward(app) + + self.login_as_director() + url = self.API_PREFIX + "director/application/reject" + r = self.api_post( + url, + { + "application_id": app.id, + "decision": "Needs Revision", + "comments": "y" * 60, + }, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.NEEDS_REVISION) + self._record_result(self._test_id, self._scenario, "Pass", actual=f"status={app.status}", evidence="") + + def test_ex01_director_reject_requires_min_50_char_feedback(self): + self._test_id = "PMS-UC-003-EX-01"; self._uc_id = "PMS-UC-003" + self._test_category = "Exception" + self._scenario = "Director rejects with too-short feedback" + self._expected_result = "400; validation error" + + app = self._submit_application_submitted(title="UC003-short") + app = self._pcc_review_and_forward(app) + + self.login_as_director() + url = self.API_PREFIX + "director/application/reject" + r = self.api_post( + url, + {"application_id": app.id, "decision": "Reject", "comments": "short"}, + expected_status=400, + ) + self._record_result(self._test_id, self._scenario, "Pass", actual=f"status={r.status_code}", evidence=r.content[:500].decode(errors="ignore")) + + +# ========================================================================= +# UC-004: Revise and Resubmit Application +# ========================================================================= +class TestPMS_UC_004(_PatentFlowMixin, UCTestBase): + + def test_hp01_resubmit_within_window(self): + self._test_id = "PMS-UC-004-HP-01"; self._uc_id = "PMS-UC-004" + self._test_category = "Happy Path" + self._scenario = "Applicant resubmits within deadline" + self._expected_result = "200; status=Resubmitted" + + app = self._submit_application_submitted(title="UC004") + app = self._pcc_review_and_forward(app) + + # move to Needs Revision + self.login_as_director() + self.api_post( + self.API_PREFIX + "director/application/reject", + {"application_id": app.id, "decision": "Needs Revision", "comments": "z" * 60}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.NEEDS_REVISION) + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/resubmit/{app.id}/" + r = self.client.post(url, {"json_data": json.dumps({"title": "UC004-updated"})}, format="multipart") + self.assertEqual(r.status_code, 200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.RESUBMITTED) + + def test_ap01_resubmit_updates_title_only(self): + self._test_id = "PMS-UC-004-AP-01"; self._uc_id = "PMS-UC-004" + self._test_category = "Alternate Paths" + self._scenario = "Applicant resubmits with partial update" + self._expected_result = "200; title updated; status=Resubmitted" + + app = self._submit_application_submitted(title="UC004-partial") + app.status = ApplicationStatus.NEEDS_REVISION + app.resubmission_deadline = now() # still valid + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/resubmit/{app.id}/" + r = self.client.post(url, {"json_data": json.dumps({"title": "UC004-new"})}, format="multipart") + self.assertEqual(r.status_code, 200) + app.refresh_from_db() + self.assertEqual(app.title, "UC004-new") + + def test_ex01_resubmit_after_deadline_marks_expired(self): + self._test_id = "PMS-UC-004-EX-01"; self._uc_id = "PMS-UC-004" + self._test_category = "Exception" + self._scenario = "Applicant resubmits after deadline" + self._expected_result = "400; status=Expired" + + app = self._submit_application_submitted(title="UC004-exp") + app.status = ApplicationStatus.NEEDS_REVISION + app.resubmission_deadline = now() - timedelta(days=1) + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/resubmit/{app.id}/" + r = self.client.post(url, {"json_data": json.dumps({"title": "late"})}, format="multipart") + self.assertEqual(r.status_code, 400) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.EXPIRED) + + +# ========================================================================= +# UC-005: Track Application Status +# ========================================================================= +class TestPMS_UC_005(_PatentFlowMixin, UCTestBase): + + def test_hp01_applicant_views_own_applications_list(self): + self._test_id = "PMS-UC-005-HP-01"; self._uc_id = "PMS-UC-005" + app = self._submit_application_pending_consent(title="UC005") + + self.login_as_applicant() + url = self.API_PREFIX + "applicant/applications/" + r = self.api_get(url, expected_status=200) + data = r.json().get("applications", []) + self.assertTrue(any(str(a.get("id")) == str(app.id) for a in data)) + + def test_ap01_applicant_views_details_of_own_application(self): + self._test_id = "PMS-UC-005-AP-01"; self._uc_id = "PMS-UC-005" + app = self._submit_application_pending_consent(title="UC005-detail") + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/details/{app.id}/" + r = self.api_get(url, expected_status=200) + self.assertEqual(str(r.json().get("id")), str(app.id)) + + def test_ex01_applicant_cannot_view_others_application(self): + self._test_id = "PMS-UC-005-EX-01"; self._uc_id = "PMS-UC-005" + app = self._submit_application_pending_consent(title="UC005-other") + + # outsider user (authenticated) but not inventor + self.login_as_outsider() + url = self.API_PREFIX + f"applicant/applications/details/{app.id}/" + self.api_get(url, expected_status=403) + + +# ========================================================================= +# UC-006: Assign Attorney +# ========================================================================= +class TestPMS_UC_006(_PatentFlowMixin, UCTestBase): + + def test_hp01_pcc_assigns_attorney_to_approved_application(self): + self._test_id = "PMS-UC-006-HP-01"; self._uc_id = "PMS-UC-006" + + app = self._submit_application_submitted(title="UC006") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/attorney/" + r = self.client.post( + url, + { + "attorney_name": "Adv Sharma", + "attorney_email": "sharma@lawfirm.com", + "specialization": "Patent Law", + }, + format="multipart", + ) + self.assertEqual(r.status_code, 201) + self.assertTrue(AttorneyAssignment.objects.filter(application=app).exists()) + + def test_ap01_pcc_updates_existing_attorney_assignment(self): + self._test_id = "PMS-UC-006-AP-01"; self._uc_id = "PMS-UC-006" + + app = self._submit_application_submitted(title="UC006-update") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/attorney/" + self.client.post(url, {"attorney_name": "A"}, format="multipart") + self.client.post(url, {"attorney_name": "B"}, format="multipart") + self.assertEqual(AttorneyAssignment.objects.get(application=app).attorney_name, "B") + + def test_ex01_attorney_assignment_blocked_when_not_approved(self): + self._test_id = "PMS-UC-006-EX-01"; self._uc_id = "PMS-UC-006" + + app = self._submit_application_submitted(title="UC006-block") + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/attorney/" + r = self.client.post(url, {"attorney_name": "Adv"}, format="multipart") + self.assertEqual(r.status_code, 400) + + +# ========================================================================= +# UC-007: Assess Application Patentability (Legal Assessment) +# ========================================================================= +class TestPMS_UC_007(_PatentFlowMixin, UCTestBase): + + def test_hp01_record_patentability_assessment(self): + self._test_id = "PMS-UC-007-HP-01"; self._uc_id = "PMS-UC-007" + + app = self._submit_application_submitted(title="UC007") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/assessment/" + r = self.client.post( + url, + { + "recommendation": "File Patent", + "opinion_summary": "This looks patentable based on novelty and utility.", + "novelty_score": 80, + "non_obviousness_score": 70, + "utility_score": 90, + "search_completeness": 95, + }, + format="multipart", + ) + self.assertEqual(r.status_code, 201) + self.assertTrue(PatentabilityAssessment.objects.filter(application=app).exists()) + + def test_ap01_invalid_recommendation_rejected(self): + self._test_id = "PMS-UC-007-AP-01"; self._uc_id = "PMS-UC-007" + + app = self._submit_application_submitted(title="UC007-bad") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/assessment/" + r = self.client.post( + url, + { + "recommendation": "Maybe", + "opinion_summary": "This is long enough to pass summary rule.", + "novelty_score": 80, + "non_obviousness_score": 70, + "utility_score": 90, + "search_completeness": 95, + }, + format="multipart", + ) + self.assertEqual(r.status_code, 400) + + def test_ex01_short_opinion_summary_rejected(self): + self._test_id = "PMS-UC-007-EX-01"; self._uc_id = "PMS-UC-007" + + app = self._submit_application_submitted(title="UC007-short") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/assessment/" + r = self.client.post( + url, + { + "recommendation": "File Patent", + "opinion_summary": "too short", + "novelty_score": 80, + "non_obviousness_score": 70, + "utility_score": 90, + "search_completeness": 95, + }, + format="multipart", + ) + self.assertEqual(r.status_code, 400) + + +# ========================================================================= +# UC-008: Manage Budgets and Financial Approval +# ========================================================================= +class TestPMS_UC_008(_PatentFlowMixin, UCTestBase): + + def test_hp01_budget_within_threshold_auto_approved_by_pcc(self): + self._test_id = "PMS-UC-008-HP-01"; self._uc_id = "PMS-UC-008" + + app = self._submit_application_submitted(title="UC008") + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/budget/" + r = self.api_post(url, {"filing_cost": 1000, "attorney_fees": 1000, "administrative_cost": 0}, expected_status=200) + self.assertIn("total_cost", r.json()) + + def test_ap01_budget_above_threshold_escalates(self): + self._test_id = "PMS-UC-008-AP-01"; self._uc_id = "PMS-UC-008" + + app = self._submit_application_submitted(title="UC008-hi") + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/budget/" + r = self.api_post(url, {"filing_cost": 200000, "attorney_fees": 0, "administrative_cost": 0}, expected_status=200) + self.assertEqual(r.json().get("decision"), "Escalated to Director") + + def test_ex01_non_pcc_cannot_create_budget(self): + self._test_id = "PMS-UC-008-EX-01"; self._uc_id = "PMS-UC-008" + + app = self._submit_application_submitted(title="UC008-non") + self.login_as_outsider() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/budget/" + self.api_post(url, {"filing_cost": 1}, expected_status=403) + + +# ========================================================================= +# UC-009: File with Patent Office / Log Official Filing +# ========================================================================= +class TestPMS_UC_009(_PatentFlowMixin, UCTestBase): + + def test_hp01_record_filing_advances_to_patent_filed(self): + self._test_id = "PMS-UC-009-HP-01"; self._uc_id = "PMS-UC-009" + + app = self._submit_application_submitted(title="UC009") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + app = self._advance_to_search_report_generated(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/filing/" + r = self.client.post(url, {"external_filing_id": "IPO/2026/001"}, format="multipart") + self.assertEqual(r.status_code, 201) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.PATENT_FILED) + self.assertTrue(FilingRecord.objects.filter(application=app).exists()) + + def test_ap01_international_filing_requires_justification(self): + self._test_id = "PMS-UC-009-AP-01"; self._uc_id = "PMS-UC-009" + + app = self._submit_application_submitted(title="UC009-intl") + app = self._pcc_review_and_forward(app) + app = self._director_approve(app) + app = self._advance_to_search_report_generated(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/filing/" + r = self.client.post( + url, + {"filing_office": "USPTO", "jurisdiction": "USA", "external_filing_id": "US-1"}, + format="multipart", + ) + self.assertEqual(r.status_code, 400) + + def test_ex01_filing_blocked_if_wrong_status(self): + self._test_id = "PMS-UC-009-EX-01"; self._uc_id = "PMS-UC-009" + + app = self._submit_application_submitted(title="UC009-wrong") + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/filing/" + r = self.client.post(url, {"external_filing_id": "IPO/2026/002"}, format="multipart") + self.assertEqual(r.status_code, 400) + + +# ========================================================================= +# UC-010: Track Application Progress and Notifications +# ========================================================================= +class TestPMS_UC_010(_PatentFlowMixin, UCTestBase): + + def test_hp01_get_notifications_endpoint_works(self): + self._test_id = "PMS-UC-010-HP-01"; self._uc_id = "PMS-UC-010" + + self.login_as_applicant() + url = self.API_PREFIX + "notifications/" + self.api_get(url, expected_status=200) + + def test_ap01_unread_count_endpoint_returns_integer(self): + self._test_id = "PMS-UC-010-AP-01"; self._uc_id = "PMS-UC-010" + + self.login_as_applicant() + url = self.API_PREFIX + "notifications/unread-count/" + r = self.api_get(url, expected_status=200) + self.assertIn("unread_count", r.json()) + self.assertIsInstance(r.json().get("unread_count"), int) + + def test_ex01_unauthenticated_cannot_fetch_notifications(self): + self._test_id = "PMS-UC-010-EX-01"; self._uc_id = "PMS-UC-010" + + self.logout() + url = self.API_PREFIX + "notifications/" + self.api_get(url, expected_status=401) + + +# ========================================================================= +# UC-011: Respond to Office Actions — NOT IMPLEMENTED (spec gap) +# ========================================================================= +@unittest.skip("PMS-UC-011 office-action handling is not implemented in current API.") +class TestPMS_UC_011(UCTestBase): + + def test_hp01_placeholder(self): + self._test_id = "PMS-UC-011-HP-01"; self._uc_id = "PMS-UC-011" + self._scenario = "Spec-only UC; API endpoints not available" + self._expected_result = "Skipped" + + def test_ap01_placeholder(self): + self._test_id = "PMS-UC-011-AP-01"; self._uc_id = "PMS-UC-011" + self._scenario = "Spec-only UC; API endpoints not available" + self._expected_result = "Skipped" + + def test_ex01_placeholder(self): + self._test_id = "PMS-UC-011-EX-01"; self._uc_id = "PMS-UC-011" + self._scenario = "Spec-only UC; API endpoints not available" + self._expected_result = "Skipped" + + +# ========================================================================= +# UC-012: Track and Manage Deadlines — no direct API to trigger deadline jobs +# ========================================================================= +@unittest.skip("PMS-UC-012 deadline jobs are not exposed via the current API.") +class TestPMS_UC_012(UCTestBase): + + def test_hp01_placeholder(self): + self._test_id = "PMS-UC-012-HP-01"; self._uc_id = "PMS-UC-012" + self._scenario = "Spec-only UC; no API trigger endpoints" + self._expected_result = "Skipped" + + def test_ap01_placeholder(self): + self._test_id = "PMS-UC-012-AP-01"; self._uc_id = "PMS-UC-012" + self._scenario = "Spec-only UC; no API trigger endpoints" + self._expected_result = "Skipped" + + def test_ex01_placeholder(self): + self._test_id = "PMS-UC-012-EX-01"; self._uc_id = "PMS-UC-012" + self._scenario = "Spec-only UC; no API trigger endpoints" + self._expected_result = "Skipped" + + +# ========================================================================= +# UC-013: Maintenance Fees and Renewals — NOT IMPLEMENTED +# ========================================================================= +@unittest.skip("PMS-UC-013 maintenance fees/renewals are not implemented in current API.") +class TestPMS_UC_013(UCTestBase): + + def test_hp01_placeholder(self): + self._test_id = "PMS-UC-013-HP-01"; self._uc_id = "PMS-UC-013" + self._scenario = "Spec-only UC; maintenance module absent" + self._expected_result = "Skipped" + + def test_ap01_placeholder(self): + self._test_id = "PMS-UC-013-AP-01"; self._uc_id = "PMS-UC-013" + self._scenario = "Spec-only UC; maintenance module absent" + self._expected_result = "Skipped" + + def test_ex01_placeholder(self): + self._test_id = "PMS-UC-013-EX-01"; self._uc_id = "PMS-UC-013" + self._scenario = "Spec-only UC; maintenance module absent" + self._expected_result = "Skipped" + + +# ========================================================================= +# UC-014: Withdraw Patent Application +# ========================================================================= +class TestPMS_UC_014(_PatentFlowMixin, UCTestBase): + + def test_hp01_applicant_withdraws_before_filing(self): + self._test_id = "PMS-UC-014-HP-01"; self._uc_id = "PMS-UC-014" + + app = self._submit_application_pending_consent(title="UC014") + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/withdraw/{app.id}/" + r = self.api_post(url, {"reason": "No longer needed"}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.WITHDRAWN) + + def test_ap01_withdraw_requires_applicant_association(self): + self._test_id = "PMS-UC-014-AP-01"; self._uc_id = "PMS-UC-014" + + app = self._submit_application_pending_consent(title="UC014-2") + self.login_as_outsider() + url = self.API_PREFIX + f"applicant/applications/withdraw/{app.id}/" + self.api_post(url, {"reason": "No"}, expected_status=403) + + def test_ex01_withdraw_blocked_after_patent_filed(self): + self._test_id = "PMS-UC-014-EX-01"; self._uc_id = "PMS-UC-014" + + app = self._submit_application_submitted(title="UC014-filed") + app.status = ApplicationStatus.PATENT_FILED + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/withdraw/{app.id}/" + self.api_post(url, {"reason": "Late"}, expected_status=400) + + +# ========================================================================= +# UC-015: Generate Reports and Analytics Dashboards +# ========================================================================= +class TestPMS_UC_015(UCTestBase): + + def test_hp01_pcc_admin_can_view_analytics(self): + self._test_id = "PMS-UC-015-HP-01"; self._uc_id = "PMS-UC-015" + self.login_as_pcc_admin() + url = self.API_PREFIX + "pccAdmin/analytics/" + self.api_get(url, expected_status=200) + + def test_ap01_analytics_summary_endpoint(self): + self._test_id = "PMS-UC-015-AP-01"; self._uc_id = "PMS-UC-015" + self.login_as_pcc_admin() + url = self.API_PREFIX + "pccAdmin/analytics/summary/" + self.api_get(url, expected_status=200) + + def test_ex01_unauthenticated_cannot_view_analytics(self): + self._test_id = "PMS-UC-015-EX-01"; self._uc_id = "PMS-UC-015" + self.logout() + url = self.API_PREFIX + "pccAdmin/analytics/" + self.api_get(url, expected_status=401) + + +# ========================================================================= +# UC-016: Manage Co-Inventors and Inventor Agreements (mapped to consent) +# ========================================================================= +class TestPMS_UC_016(_PatentFlowMixin, UCTestBase): + + def test_hp01_submission_creates_pending_consent_for_coinventor(self): + self._test_id = "PMS-UC-016-HP-01"; self._uc_id = "PMS-UC-016" + + app = self._submit_application_pending_consent(title="UC016") + self.login_as_coinventor() + url = self.API_PREFIX + "applicant/applications/pending-consent/" + r = self.api_get(url, expected_status=200) + self.assertTrue(any(str(x.get("application_id")) == str(app.id) for x in r.json())) + + def test_ap01_consent_can_be_revoked_only_in_draft_or_needs_revision(self): + self._test_id = "PMS-UC-016-AP-01"; self._uc_id = "PMS-UC-016" + + app = self._submit_application_pending_consent(title="UC016-revoke") + self.login_as_coinventor() + self.post_give_consent(app.id) + + # revocation endpoint exists + revoke_url = self.API_PREFIX + f"applicant/applications/{app.id}/consent/revoke/" + # app is still pending consent; service allows only Draft or Needs Revision → should 400 + self.api_post(revoke_url, {}, expected_status=400) + + def test_ex01_submission_blocked_if_inventor_shares_not_100(self): + self._test_id = "PMS-UC-016-EX-01"; self._uc_id = "PMS-UC-016" + + self.login_as_applicant() + payload = self.make_submit_payload( + title="UC016-badshares", + inventor_shares=[(self.applicant_user, 60), (self.coinventor_user, 60)], + ) + resp, _ = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 400) + + +# ========================================================================= +# UC-017: Patent Licensing / Tech Transfer Request — NOT IMPLEMENTED +# ========================================================================= +@unittest.skip("PMS-UC-017 licensing/tech-transfer is not implemented in current API.") +class TestPMS_UC_017(UCTestBase): + + def test_hp01_placeholder(self): + self._test_id = "PMS-UC-017-HP-01"; self._uc_id = "PMS-UC-017" + self._scenario = "Spec-only UC; licensing endpoints absent" + self._expected_result = "Skipped" + + def test_ap01_placeholder(self): + self._test_id = "PMS-UC-017-AP-01"; self._uc_id = "PMS-UC-017" + self._scenario = "Spec-only UC; licensing endpoints absent" + self._expected_result = "Skipped" + + def test_ex01_placeholder(self): + self._test_id = "PMS-UC-017-EX-01"; self._uc_id = "PMS-UC-017" + self._scenario = "Spec-only UC; licensing endpoints absent" + self._expected_result = "Skipped" + + +# ========================================================================= +# UC-018: Appeal Against Rejection +# ========================================================================= +class TestPMS_UC_018(_PatentFlowMixin, UCTestBase): + + def test_hp01_applicant_lodges_appeal_for_rejected_application(self): + self._test_id = "PMS-UC-018-HP-01"; self._uc_id = "PMS-UC-018" + + app = self._submit_application_submitted(title="UC018") + app.status = ApplicationStatus.REJECTED + app.decision_date = now() + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/{app.id}/appeal/" + self.api_post(url, {"reason": "a" * 60}, expected_status=201) + + def test_ap01_pcc_forwards_appeal_to_director(self): + self._test_id = "PMS-UC-018-AP-01"; self._uc_id = "PMS-UC-018" + + app = self._submit_application_submitted(title="UC018-fwd") + app.status = ApplicationStatus.APPEAL + app.save() + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/appeal/review/" + self.api_post(url, {}, expected_status=200) + + def test_ex01_appeal_reason_requires_min_50_chars(self): + self._test_id = "PMS-UC-018-EX-01"; self._uc_id = "PMS-UC-018" + + app = self._submit_application_submitted(title="UC018-short") + app.status = ApplicationStatus.REJECTED + app.decision_date = now() + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/{app.id}/appeal/" + self.api_post(url, {"reason": "short"}, expected_status=400) + + +# ========================================================================= +# UC-019: Search Prior Art (mapped to implemented global search/filter endpoint) +# ========================================================================= +class TestPMS_UC_019(UCTestBase): + + def test_hp01_search_by_query_returns_results_structure(self): + self._test_id = "PMS-UC-019-HP-01"; self._uc_id = "PMS-UC-019" + self._test_category = "Happy Path" + self._scenario = "Search applications by keyword" + self._expected_result = "200; returns results with total + items" + + self.login_as_applicant() + url = self.API_PREFIX + "search/" + r = self.api_get(url, expected_status=200, query={"q": "test"}) + body = r.json() + self.assertIn("items", body) + self.assertIn("total", body) + + def test_ap01_search_filters_by_status(self): + self._test_id = "PMS-UC-019-AP-01"; self._uc_id = "PMS-UC-019" + self._test_category = "Alternate Paths" + self._scenario = "Search with status filter" + self._expected_result = "200; filtered result set" + + self.login_as_applicant() + url = self.API_PREFIX + "search/" + r = self.api_get(url, expected_status=200, query={"status": [ApplicationStatus.SUBMITTED]}) + body = r.json() + self.assertIn("items", body) + + def test_ex01_search_rejects_invalid_date_format(self): + self._test_id = "PMS-UC-019-EX-01"; self._uc_id = "PMS-UC-019" + self.login_as_applicant() + url = self.API_PREFIX + "search/" + # invalid date_from should be treated as None and still 200 + r = self.api_get(url, expected_status=200, query={"date_from": "not-a-date"}) + self.assertIn("items", r.json()) + + +# ========================================================================= +# UC-020: Manage Document Versions +# ========================================================================= +class TestPMS_UC_020(_PatentFlowMixin, UCTestBase): + + def test_hp01_upload_document_creates_version_1(self): + self._test_id = "PMS-UC-020-HP-01"; self._uc_id = "PMS-UC-020" + + app = self._submit_application_pending_consent(title="UC020") + self.login_as_applicant() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/documents/" + f = SimpleUploadedFile("doc.pdf", b"%PDF-1.4 test", content_type="application/pdf") + r = self.client.post( + url, + {"document_type": "POC", "title": "POC", "file": f, "description": "v1"}, + format="multipart", + ) + self.assertEqual(r.status_code, 201) + self.assertEqual(r.json().get("version"), 1) + + def test_ap01_upload_document_same_type_increments_version(self): + self._test_id = "PMS-UC-020-AP-01"; self._uc_id = "PMS-UC-020" + + app = self._submit_application_pending_consent(title="UC020-v") + self.login_as_applicant() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/documents/" + f1 = SimpleUploadedFile("doc1.pdf", b"%PDF-1.4 v1", content_type="application/pdf") + f2 = SimpleUploadedFile("doc2.pdf", b"%PDF-1.4 v2", content_type="application/pdf") + self.client.post(url, {"document_type": "MOU", "title": "MOU", "file": f1}, format="multipart") + r2 = self.client.post(url, {"document_type": "MOU", "title": "MOU", "file": f2}, format="multipart") + self.assertEqual(r2.status_code, 201) + self.assertEqual(r2.json().get("version"), 2) + + def test_ex01_non_inventor_cannot_upload_document(self): + self._test_id = "PMS-UC-020-EX-01"; self._uc_id = "PMS-UC-020" + + app = self._submit_application_pending_consent(title="UC020-no") + self.login_as_outsider() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/documents/" + f = SimpleUploadedFile("doc.pdf", b"%PDF-1.4 test", content_type="application/pdf") + r = self.client.post(url, {"document_type": "POC", "title": "POC", "file": f}, format="multipart") + self.assertEqual(r.status_code, 403) diff --git a/FusionIIIT/applications/patent_system/tests/test_workflows.py b/FusionIIIT/applications/patent_system/tests/test_workflows.py new file mode 100644 index 000000000..cd0533e9b --- /dev/null +++ b/FusionIIIT/applications/patent_system/tests/test_workflows.py @@ -0,0 +1,236 @@ +import json +from datetime import timedelta + +from django.utils.timezone import now + +from applications.patent_system.models import Application, ApplicationStatus, BudgetDecision + +from .base import WFTestBase + + +class _PatentWFBaseMixin: + def _submit(self, *, title="WF Patent", inventor_shares=None): + self.login_as_applicant() + payload = self.make_submit_payload(title=title, inventor_shares=inventor_shares) + resp, app_id = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 201, msg=getattr(resp, "content", b"")[:500]) + return Application.objects.get(id=app_id) + + def _consent_all(self, app: Application): + self.login_as_applicant() + self.api_post(self.API_PREFIX + f"applicant/applications/{app.id}/consent/", {}, expected_status=200) + self.login_as_coinventor() + self.api_post(self.API_PREFIX + f"applicant/applications/{app.id}/consent/", {}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.SUBMITTED) + return app + + def _review_and_forward(self, app: Application): + self.login_as_pcc_admin() + self.api_post( + self.API_PREFIX + f"pccAdmin/applications/new/review/{app.id}/", + {"comments": "Reviewed"}, + expected_status=200, + ) + self.api_post( + self.API_PREFIX + f"pccAdmin/applications/new/forward/{app.id}/", + {"comments": "Forwarded"}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.FORWARDED) + return app + + def _director_accept(self, app: Application): + self.login_as_director() + self.api_post( + self.API_PREFIX + "director/application/accept", + {"application_id": app.id, "comments": "Approved"}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.APPROVED) + return app + + def _advance_to_search_report_generated(self, app: Application): + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/ongoing/changeStatus/{app.id}/" + for st in [ + ApplicationStatus.PATENTABILITY_CHECK_STARTED, + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED, + ApplicationStatus.SEARCH_REPORT_GENERATED, + ]: + self.api_post(url, {"next_status": st}, expected_status=200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.SEARCH_REPORT_GENERATED) + return app + + +# ========================= +# WF-101: Submit Patent Application +# ========================= +class TestPMS_WF_101(_PatentWFBaseMixin, WFTestBase): + + def test_e2e_submit_application(self): + self._test_id = "PMS-WF-101-E2E-01"; self._wf_id = "PMS-WF-101" + app = self._submit(title="WF101") + self.assertEqual(app.status, ApplicationStatus.PENDING_INVENTOR_CONSENT) + + def test_negative_unauthorized(self): + self._test_id = "PMS-WF-101-NEG-01"; self._wf_id = "PMS-WF-101" + self.logout() + payload = self.make_submit_payload(title="WF101-unauth") + resp, _ = self.post_submit_application(payload) + self.assertEqual(resp.status_code, 401) + + +# ========================= +# WF-201: Revision Workflow +# ========================= +class TestPMS_WF_201(_PatentWFBaseMixin, WFTestBase): + + def test_e2e_resubmit(self): + self._test_id = "PMS-WF-201-E2E-01"; self._wf_id = "PMS-WF-201" + app = self._consent_all(self._submit(title="WF201")) + + self.login_as_pcc_admin() + self.api_post( + self.API_PREFIX + f"pccAdmin/applications/new/requestModification/{app.id}/", + {"comments": "Please revise."}, + expected_status=200, + ) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.NEEDS_REVISION) + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/resubmit/{app.id}/" + r = self.client.post(url, {"json_data": json.dumps({"title": "WF201-new"})}, format="multipart") + self.assertEqual(r.status_code, 200) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.RESUBMITTED) + + def test_negative_expired(self): + self._test_id = "PMS-WF-201-NEG-01"; self._wf_id = "PMS-WF-201" + app = self._consent_all(self._submit(title="WF201-exp")) + app.status = ApplicationStatus.NEEDS_REVISION + app.resubmission_deadline = now() - timedelta(days=1) + app.save() + + self.login_as_applicant() + url = self.API_PREFIX + f"applicant/applications/resubmit/{app.id}/" + r = self.client.post(url, {"json_data": json.dumps({"title": "late"})}, format="multipart") + self.assertEqual(r.status_code, 400) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.EXPIRED) + + +# ========================= +# WF-301: Budget Workflow +# ========================= +class TestPMS_WF_301(_PatentWFBaseMixin, WFTestBase): + + def test_e2e_budget_approval(self): + self._test_id = "PMS-WF-301-E2E-01"; self._wf_id = "PMS-WF-301" + app = self._consent_all(self._submit(title="WF301")) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/budget/" + r = self.api_post(url, {"filing_cost": 1000, "attorney_fees": 0, "administrative_cost": 0}, expected_status=200) + self.assertEqual(r.json().get("decision"), BudgetDecision.APPROVED_PCC) + + def test_negative_budget_denied(self): + self._test_id = "PMS-WF-301-NEG-01"; self._wf_id = "PMS-WF-301" + app = self._consent_all(self._submit(title="WF301-escalate")) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/budget/" + r = self.api_post(url, {"filing_cost": 999999, "attorney_fees": 0, "administrative_cost": 0}, expected_status=200) + self.assertEqual(r.json().get("decision"), BudgetDecision.ESCALATED) + + self.login_as_director() + durl = self.API_PREFIX + f"director/budget/{app.id}/decision/" + self.api_post(durl, {"decision": "Reject", "remarks": "no"}, expected_status=200) + + +# ========================= +# WF-401: Director Assignment (forwarding) +# ========================= +class TestPMS_WF_401(_PatentWFBaseMixin, WFTestBase): + + def test_e2e_assignment(self): + self._test_id = "PMS-WF-401-E2E-01"; self._wf_id = "PMS-WF-401" + app = self._consent_all(self._submit(title="WF401")) + self._review_and_forward(app) + self._director_accept(app) + + def test_negative_no_director(self): + self._test_id = "PMS-WF-401-NEG-01"; self._wf_id = "PMS-WF-401" + app = self._consent_all(self._submit(title="WF401-non")) + self._review_and_forward(app) + + self.login_as_applicant() + self.api_post( + self.API_PREFIX + "director/application/accept", + {"application_id": app.id, "comments": "Approved"}, + expected_status=403, + ) + + +# ========================= +# WF-501: Post Grant Workflow (ongoing status transitions) +# ========================= +class TestPMS_WF_501(_PatentWFBaseMixin, WFTestBase): + + def test_e2e_fee_payment(self): + self._test_id = "PMS-WF-501-E2E-01"; self._wf_id = "PMS-WF-501" + app = self._consent_all(self._submit(title="WF501")) + self._review_and_forward(app) + self._director_accept(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/ongoing/changeStatus/{app.id}/" + self.api_post(url, {"next_status": ApplicationStatus.PATENTABILITY_CHECK_STARTED}, expected_status=200) + + def test_negative_fee_missed(self): + self._test_id = "PMS-WF-501-NEG-01"; self._wf_id = "PMS-WF-501" + app = self._consent_all(self._submit(title="WF501-bad")) + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/ongoing/changeStatus/{app.id}/" + self.api_post(url, {"next_status": ApplicationStatus.PATENT_FILED}, expected_status=400) + + +# ========================= +# WF-601: External Filing Workflow +# ========================= +class TestPMS_WF_601(_PatentWFBaseMixin, WFTestBase): + + def test_e2e_filing(self): + self._test_id = "PMS-WF-601-E2E-01"; self._wf_id = "PMS-WF-601" + app = self._consent_all(self._submit(title="WF601")) + self._review_and_forward(app) + self._director_accept(app) + self._advance_to_search_report_generated(app) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/filing/" + r = self.client.post( + url, + {"filing_office": "Indian Patent Office", "jurisdiction": "India", "external_filing_id": "IN-001"}, + format="multipart", + ) + self.assertEqual(r.status_code, 201) + app.refresh_from_db() + self.assertEqual(app.status, ApplicationStatus.PATENT_FILED) + + def test_negative_filing_error(self): + self._test_id = "PMS-WF-601-NEG-01"; self._wf_id = "PMS-WF-601" + app = self._consent_all(self._submit(title="WF601-early")) + + self.login_as_pcc_admin() + url = self.API_PREFIX + f"pccAdmin/applications/{app.id}/filing/" + r = self.client.post( + url, + {"filing_office": "Indian Patent Office", "jurisdiction": "India", "external_filing_id": "IN-002"}, + format="multipart", + ) + self.assertEqual(r.status_code, 400) \ No newline at end of file diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py b/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py index 2afda5843..c532dbfe8 100644 --- a/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py +++ b/FusionIIIT/applications/programme_curriculum/migrations/0026_add_database_indexes.py @@ -9,41 +9,42 @@ class Migration(migrations.Migration): dependencies = [ ('programme_curriculum', '0025_update_minority_values'), + ('academic_procedures', '0001_initial') ] operations = [ # Add database indexes for optimized query performance - migrations.RunSQL( - sql=[ - # Main composite index for course registration queries - """ - CREATE INDEX IF NOT EXISTS idx_course_reg_main_query - ON course_registration(session, semester_type, course_id_id, registration_type, student_id_id); - """, + # migrations.RunSQL( + # sql=[ + # # Main composite index for course registration queries + # """ + # CREATE INDEX IF NOT EXISTS idx_course_reg_main_query + # ON course_registration(session, semester_type, course_id_id, registration_type, student_id_id); + # """, - # Individual indexes for course registration - """ - CREATE INDEX IF NOT EXISTS idx_course_reg_session_semester_course - ON course_registration(session, semester_type, course_id_id); - """, + # # Individual indexes for course registration + # """ + # CREATE INDEX IF NOT EXISTS idx_course_reg_session_semester_course + # ON course_registration(session, semester_type, course_id_id); + # """, - """ - CREATE INDEX IF NOT EXISTS idx_course_reg_student - ON course_registration(student_id_id); - """, + # """ + # CREATE INDEX IF NOT EXISTS idx_course_reg_student + # ON course_registration(student_id_id); + # """, - """ - CREATE INDEX IF NOT EXISTS idx_course_reg_type - ON course_registration(registration_type); - """ - ], + # """ + # CREATE INDEX IF NOT EXISTS idx_course_reg_type + # ON course_registration(registration_type); + # """ + # ], - # Reverse migration to drop indexes - reverse_sql=[ - "DROP INDEX IF EXISTS idx_course_reg_main_query;", - "DROP INDEX IF EXISTS idx_course_reg_session_semester_course;", - "DROP INDEX IF EXISTS idx_course_reg_student;", - "DROP INDEX IF EXISTS idx_course_reg_type;" - ] - ) + # # Reverse migration to drop indexes + # reverse_sql=[ + # "DROP INDEX IF EXISTS idx_course_reg_main_query;", + # "DROP INDEX IF EXISTS idx_course_reg_session_semester_course;", + # "DROP INDEX IF EXISTS idx_course_reg_student;", + # "DROP INDEX IF EXISTS idx_course_reg_type;" + # ] + # ) ] diff --git a/FusionIIIT/setup_tests.py b/FusionIIIT/setup_tests.py new file mode 100644 index 000000000..5915da5ef --- /dev/null +++ b/FusionIIIT/setup_tests.py @@ -0,0 +1,287 @@ +import os +import sys + +# The exact Custom Runner logic built during the Scholarship implementation +RUNNER_CODE = """\"\"\" +runner.py — Custom Django test runner + CSV report generator. +Generates all 7 required CSV deliverable sheets. +\"\"\" + +import csv +import os +import traceback +from datetime import datetime +from unittest import TestResult + +import yaml +from django.test.runner import DiscoverRunner + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_SPECS_DIR = os.path.join(_THIS_DIR, 'specs') +_REPORTS_DIR = os.path.join(_THIS_DIR, 'reports') + +def _ensure_reports_dir(): + os.makedirs(_REPORTS_DIR, exist_ok=True) + +def _load_yaml(filename): + path = os.path.join(_SPECS_DIR, filename) + if not os.path.exists(path): + return {} + with open(path, 'r', encoding='utf-8') as f: + return yaml.safe_load(f) or {} + +class ReportingTestResult(TestResult): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.test_records = [] + self.tester_name = os.environ.get('TESTER_NAME', 'Tester') + + def _extract_metadata(self, test): + return { + 'test_id': getattr(test, '_test_id', '') or '', + 'uc_id': getattr(test, '_uc_id', '') or '', + 'br_id': getattr(test, '_br_id', '') or '', + 'wf_id': getattr(test, '_wf_id', '') or '', + 'test_category': getattr(test, '_test_category', '') or '', + 'scenario': getattr(test, '_scenario', '') or '', + 'preconditions': getattr(test, '_preconditions', '') or '', + 'input_action': getattr(test, '_input_action', '') or '', + 'expected_result': getattr(test, '_expected_result', '') or '', + 'results': list(getattr(test, '_results', [])), + 'steps': list(getattr(test, '_steps', [])), + } + + def addSuccess(self, test): + super().addSuccess(test) + meta = self._extract_metadata(test) + meta['outcome'] = 'Pass' + meta['error'] = '' + self.test_records.append(meta) + + def addFailure(self, test, err): + super().addFailure(test, err) + meta = self._extract_metadata(test) + meta['outcome'] = 'Fail' + meta['error'] = ''.join(traceback.format_exception(*err)) + self.test_records.append(meta) + + def addError(self, test, err): + super().addError(test, err) + meta = self._extract_metadata(test) + meta['outcome'] = 'Error' + meta['error'] = ''.join(traceback.format_exception(*err)) + self.test_records.append(meta) + +def _write_csv(filename, headers, rows): + path = os.path.join(_REPORTS_DIR, filename) + with open(path, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + writer.writerow(headers) + writer.writerows(rows) + +def generate_uc_test_design(): + specs = _load_yaml('use_cases.yaml') + rows = [] + for uc in specs.get('use_cases', []): + uc_id = uc.get('id', '') + title = uc.get('title', '') + for hp in uc.get('happy_paths', []): + rows.append([uc_id, title, 'Happy Path', hp.get('scenario', ''), hp.get('preconditions', ''), hp.get('input_action', ''), hp.get('expected_result', '')]) + for ap in uc.get('alternate_paths', []): + rows.append([uc_id, title, 'Alternate Path', ap.get('scenario', ''), ap.get('preconditions', ''), ap.get('input_action', ''), ap.get('expected_result', '')]) + for ex in uc.get('exception_paths', []): + rows.append([uc_id, title, 'Exception', ex.get('scenario', ''), ex.get('preconditions', ''), ex.get('input_action', ''), ex.get('expected_result', '')]) + _write_csv('UC_Test_Design.csv', ['UC_ID', 'Title', 'Category', 'Scenario', 'Preconditions', 'Input/Action', 'Expected Result'], rows) + return rows + +def generate_br_test_design(): + specs = _load_yaml('business_rules.yaml') + rows = [] + for br in specs.get('business_rules', []): + br_id = br.get('id', '') + title = br.get('title', '') + for vt in br.get('valid_tests', []): + rows.append([br_id, title, 'Valid', vt.get('input_action', ''), vt.get('expected_result', '')]) + for it in br.get('invalid_tests', []): + rows.append([br_id, title, 'Invalid', it.get('input_action', ''), it.get('expected_result', '')]) + _write_csv('BR_Test_Design.csv', ['BR_ID', 'Title', 'Category', 'Input/Action', 'Expected Result'], rows) + return rows + +def generate_wf_test_design(): + specs = _load_yaml('workflows.yaml') + rows = [] + for wf in specs.get('workflows', []): + wf_id = wf.get('id', '') + title = wf.get('title', '') + for e2e in wf.get('e2e_tests', []): + rows.append([wf_id, title, 'End-to-End', e2e.get('scenario', ''), e2e.get('expected_final_state', '')]) + for neg in wf.get('negative_tests', []): + rows.append([wf_id, title, 'Negative', neg.get('scenario', ''), neg.get('expected_final_state', '')]) + _write_csv('WF_Test_Design.csv', ['WF_ID', 'Title', 'Category', 'Scenario', 'Expected Final State'], rows) + return rows + +def generate_execution_log(records, tester_name): + rows = [] + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + for rec in records: + test_id = rec.get('test_id', 'N/A') + outcome = rec.get('outcome', 'N/A') + results = rec.get('results', []) + actual = results[0].get('actual', '') if results else '' + evidence = results[0].get('evidence', '') if results else '' + status_from_results = results[0].get('status', outcome) if results else outcome + rows.append([test_id, rec.get('scenario', ''), rec.get('input_action', ''), rec.get('expected_result', ''), actual, status_from_results, evidence, tester_name, timestamp]) + _write_csv('Test_Execution_Log.csv', ['Test_ID', 'Scenario', 'Input/Action', 'Expected Result', 'Actual Result', 'Status', 'Evidence', 'Tester', 'Timestamp'], rows) + return rows + +def generate_defect_log(records): + rows = [] + for rec in records: + if rec.get('outcome') in ('Fail', 'Error'): + rows.append([rec.get('test_id', 'N/A'), rec.get('scenario', ''), rec.get('outcome', ''), str(rec.get('error', ''))[:500], 'Open', 'High' if rec.get('outcome') == 'Error' else 'Medium']) + _write_csv('Defect_Log.csv', ['Test_ID', 'Scenario', 'Outcome', 'Error Details', 'Status', 'Severity'], rows) + return rows + +def _evaluate_status(items, pass_key, spec_type): + if not items: + return 'Not Implemented' if spec_type == 'UC' else 'Not Enforced' if spec_type == 'BR' else 'Missing' + total = len(items) + passed = sum(1 for i in items if i == 'Pass') + failed = sum(1 for i in items if i in ('Fail', 'Error')) + if passed == total: + return 'Implemented Correctly' if spec_type == 'UC' else 'Enforced Correctly' if spec_type == 'BR' else 'Complete' + elif passed > 0: + return 'Partially Implemented' if spec_type == 'UC' else 'Partially Enforced' if spec_type == 'BR' else 'Partial' + elif failed == total: + return 'Incorrectly Implemented' if spec_type == 'UC' else 'Incorrectly Enforced' if spec_type == 'BR' else 'Incorrect' + return 'Not Implemented' if spec_type == 'UC' else 'Not Enforced' if spec_type == 'BR' else 'Missing' + +def generate_artifact_evaluation(records): + uc_outcomes, br_outcomes, wf_outcomes = {}, {}, {} + for rec in records: + out = rec.get('outcome', 'N/A') + if rec.get('uc_id'): uc_outcomes.setdefault(rec.get('uc_id'), []).append(out) + if rec.get('br_id'): br_outcomes.setdefault(rec.get('br_id'), []).append(out) + if rec.get('wf_id'): wf_outcomes.setdefault(rec.get('wf_id'), []).append(out) + rows = [] + for uid, outs in sorted(uc_outcomes.items()): rows.append([uid, 'Use Case', _evaluate_status(outs, 'Pass', 'UC'), f"{outs.count('Pass')}/{len(outs)} passed"]) + for bid, outs in sorted(br_outcomes.items()): rows.append([bid, 'Business Rule', _evaluate_status(outs, 'Pass', 'BR'), f"{outs.count('Pass')}/{len(outs)} passed"]) + for wid, outs in sorted(wf_outcomes.items()): rows.append([wid, 'Workflow', _evaluate_status(outs, 'Pass', 'WF'), f"{outs.count('Pass')}/{len(outs)} passed"]) + _write_csv('Artifact_Evaluation.csv', ['Artifact_ID', 'Type', 'Status', 'Details'], rows) + return rows + +def generate_module_test_summary(records, uc_designs, br_designs, wf_designs): + specs_uc, specs_br, specs_wf = _load_yaml('use_cases.yaml'), _load_yaml('business_rules.yaml'), _load_yaml('workflows.yaml') + num_ucs, num_brs, num_wfs = len(specs_uc.get('use_cases', [])), len(specs_br.get('business_rules', [])), len(specs_wf.get('workflows', [])) + req_uc, req_br, req_wf = 3 * num_ucs, 2 * num_brs, 2 * num_wfs + des_uc, des_br, des_wf = len(uc_designs), len(br_designs), len(wf_designs) + total_exec = len(records) + total_pass = sum(1 for r in records if r.get('outcome') == 'Pass') + total_fail = sum(1 for r in records if r.get('outcome') in ('Fail', 'Error')) + total_partial = sum(1 for r in records if any(res.get('status') == 'Partial' for res in r.get('results', []))) + rows = [ + ['Total Use Cases', num_ucs], ['Total Business Rules', num_brs], ['Total Workflows', num_wfs], + ['Required UC Tests', req_uc], ['Designed UC Tests', des_uc], + ['Required BR Tests', req_br], ['Designed BR Tests', des_br], + ['Required WF Tests', req_wf], ['Designed WF Tests', des_wf], + ['UC Adequacy %', f"{(des_uc / req_uc * 100) if req_uc else 0:.1f}%"], + ['BR Adequacy %', f"{(des_br / req_br * 100) if req_br else 0:.1f}%"], + ['WF Adequacy %', f"{(des_wf / req_wf * 100) if req_wf else 0:.1f}%"], + ['Total Tests Executed', total_exec], ['Total Pass', total_pass], ['Total Partial', total_partial], ['Total Fail', total_fail], + ['Strict Pass Rate %', f"{(total_pass / total_exec * 100) if total_exec else 0:.1f}%"] + ] + _write_csv('Module_Test_Summary.csv', ['Metric', 'Value'], rows) + return rows + +class ReportingTestRunner(DiscoverRunner): + def get_resultclass(self): return ReportingTestResult + def run_suite(self, suite, **kwargs): + result = ReportingTestResult() + suite.run(result) + return result + def suite_result(self, suite, result, **kwargs): + _ensure_reports_dir() + uc, br, wf = generate_uc_test_design(), generate_br_test_design(), generate_wf_test_design() + generate_execution_log(result.test_records, result.tester_name) + generate_defect_log(result.test_records) + generate_artifact_evaluation(result.test_records) + generate_module_test_summary(result.test_records, uc, br, wf) + print(f"\\nReports saved to: {_REPORTS_DIR}\\n") + return super().suite_result(suite, result, **kwargs) +""" + +CONFTEST_SCAFFOLD = """\"\"\" +conftest.py — Initial setup scaffold. +Customize this file with your module's specific logic. +\"\"\" +from django.test import TestCase + +class BaseModuleTestCase(TestCase): + @classmethod + def setUpTestData(cls): + pass # Add your module setup here +""" + +def generate_spec_scaffold(title): + return f"""{title}: + - id: "TODO-1" + title: "Example Title" +""" + +def create_init_file(path_dir): + """Ensures directories act as python packages (prevents ntpath errors on windows test runner).""" + if not os.path.exists(path_dir): + return + init_path = os.path.join(path_dir, '__init__.py') + if not os.path.exists(init_path): + with open(init_path, 'w') as f: + f.write("# Package marker\\n") + +def main(): + if len(sys.argv) < 2: + print("Usage: python setup_tests.py ") + sys.exit(1) + + module = sys.argv[1] + base_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'applications', module) + + if not os.path.exists(base_dir): + print(f"Error: Module directory '{base_dir}' does not exist.") + sys.exit(1) + + tests_dir = os.path.join(base_dir, 'tests') + specs_dir = os.path.join(tests_dir, 'specs') + + # Create directories + os.makedirs(specs_dir, exist_ok=True) + + # Secure the application tree (Crucial fix for Windows runner discovery bug) + create_init_file(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'applications')) + create_init_file(base_dir) + create_init_file(tests_dir) + create_init_file(specs_dir) + + # Scaffolding Files + files_to_write = { + os.path.join(tests_dir, 'runner.py'): RUNNER_CODE, + os.path.join(tests_dir, 'conftest.py'): CONFTEST_SCAFFOLD, + os.path.join(tests_dir, 'test_use_cases.py'): "# UC tests\\n", + os.path.join(tests_dir, 'test_business_rules.py'): "# BR tests\\n", + os.path.join(tests_dir, 'test_workflows.py'): "# WF tests\\n", + os.path.join(specs_dir, 'use_cases.yaml'): generate_spec_scaffold('use_cases'), + os.path.join(specs_dir, 'business_rules.yaml'): generate_spec_scaffold('business_rules'), + os.path.join(specs_dir, 'workflows.yaml'): generate_spec_scaffold('workflows'), + } + + for path, content in files_to_write.items(): + if not os.path.exists(path): + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + print(f"Created: {path}") + else: + print(f"Skipped (already exists): {path}") + + print(f"\\n✅ Successfully scaffolded testing framework for '{module}'!") + +if __name__ == '__main__': + main() From 8bee7ad503edb6b2559fe85271bab8a2568b5755 Mon Sep 17 00:00:00 2001 From: Tanaybaviskar Date: Mon, 20 Apr 2026 02:47:15 +0530 Subject: [PATCH 2/4] backend working fine applied all features --- .../applications/patent_system/api/urls.py | 1 + .../applications/patent_system/api/views.py | 30 ++- .../applications/patent_system/selectors.py | 31 ++- .../applications/patent_system/services.py | 178 +++++++++++++++++- 4 files changed, 227 insertions(+), 13 deletions(-) diff --git a/FusionIIIT/applications/patent_system/api/urls.py b/FusionIIIT/applications/patent_system/api/urls.py index ea92c720c..6020c225e 100644 --- a/FusionIIIT/applications/patent_system/api/urls.py +++ b/FusionIIIT/applications/patent_system/api/urls.py @@ -27,6 +27,7 @@ path("pccAdmin/applications/new/", views.new_applications, name="pms_pcc_new"), path("pccAdmin/applications/new/review//", views.review_application, name="pms_pcc_review"), path("pccAdmin/applications/new/forward//", views.forward_application, name="pms_pcc_forward"), + path("pccAdmin/directors/", views.get_directors, name="pms_pcc_directors"), path("pccAdmin/applications/new/requestModification//", views.request_application_modification, name="pms_pcc_modify"), path("pccAdmin/applications/ongoing/", views.ongoing_applications, name="pms_pcc_ongoing"), path("pccAdmin/applications/ongoing/changeStatus//", views.change_application_status, name="pms_pcc_change_status"), diff --git a/FusionIIIT/applications/patent_system/api/views.py b/FusionIIIT/applications/patent_system/api/views.py index 218df4807..bf13c79ea 100644 --- a/FusionIIIT/applications/patent_system/api/views.py +++ b/FusionIIIT/applications/patent_system/api/views.py @@ -17,6 +17,8 @@ api_view, permission_classes, authentication_classes, ) +from applications.globals.models import Designation, HoldsDesignation + from ..models import ApplicationStatus, Document, PatentNotification, ApplicationDocument from .. import services, selectors from .serializers import ( @@ -184,6 +186,27 @@ def _do(): return _service_response(_do) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def get_directors(request): + """Get list of users with Director designation.""" + try: + designation = Designation.objects.get(name="Director") + holds_designation = HoldsDesignation.objects.filter(designation=designation).select_related('user') + directors = [ + { + "id": hd.user.id, + "name": f"{hd.user.first_name} {hd.user.last_name}".strip() or hd.user.username, + "email": hd.user.email + } + for hd in holds_designation + ] + return JsonResponse({"directors": directors}, safe=False) + except Designation.DoesNotExist: + return JsonResponse({"directors": []}, safe=False) + + @api_view(["POST"]) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) @@ -192,7 +215,8 @@ def forward_application(request, application_id): def _do(): data = json.loads(request.body or "{}") comments = data.get("comments", "") - app = services.forward_to_director(request.user, application_id, comments) + director_id = data.get("director_id", None) + app = services.forward_to_director(request.user, application_id, comments, director_id) return JsonResponse({ "message": "Application forwarded to Director.", "application_id": app.id, @@ -389,7 +413,7 @@ def analytics(request): @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def director_new_applications(request): - return JsonResponse({"applications": selectors.get_director_new_applications()}, safe=False) + return JsonResponse({"applications": selectors.get_director_new_applications(user=request.user)}, safe=False) @api_view(["POST"]) @@ -438,7 +462,7 @@ def _do(): @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def director_reviewed_applications(request): - return JsonResponse({"applications": selectors.get_director_reviewed_applications()}, safe=False) + return JsonResponse({"applications": selectors.get_director_reviewed_applications(user=request.user)}, safe=False) @api_view(["GET"]) diff --git a/FusionIIIT/applications/patent_system/selectors.py b/FusionIIIT/applications/patent_system/selectors.py index 381d7b04e..faa796b16 100644 --- a/FusionIIIT/applications/patent_system/selectors.py +++ b/FusionIIIT/applications/patent_system/selectors.py @@ -30,6 +30,7 @@ from django.db.models import Q, Count from django.shortcuts import get_object_or_404 +from django.utils.timezone import now from .models import ( Application, ApplicationStatus, DecisionStatus, @@ -307,6 +308,12 @@ def get_pending_consent_applications(user): # PCC Admin selectors # --------------------------------------------------------------------------- +def _calculate_priority_score(app): + """BR-PMS-015 — Calculate priority score based on days since submission.""" + if not app.submitted_date: + return 0 + return (now() - app.submitted_date).days + def get_new_applications_pcc(): """Applications with Submitted / Reviewed / Resubmitted status (UC-005 old).""" statuses = [ApplicationStatus.SUBMITTED, ApplicationStatus.REVIEWED, ApplicationStatus.RESUBMITTED] @@ -321,7 +328,10 @@ def get_new_applications_pcc(): "department": info.get("department", "Unknown"), "submitted_on": a.submitted_date.strftime("%Y-%m-%d") if a.submitted_date else "Unknown", "status": a.status, + "priority_score": _calculate_priority_score(a), } + # Sort the dictionary based on priority_score descending (highest score/oldest first) + result = dict(sorted(result.items(), key=lambda item: item[1]["priority_score"], reverse=True)) return result @@ -348,7 +358,10 @@ def get_ongoing_applications_pcc(): "department": info.get("department", "Unknown"), "submitted_on": a.submitted_date.strftime("%Y-%m-%d") if a.submitted_date else "Unknown", "status": a.status, + "priority_score": _calculate_priority_score(a), } + # Sort the dictionary based on priority_score descending (highest score/oldest first) + result = dict(sorted(result.items(), key=lambda item: item[1]["priority_score"], reverse=True)) return result @@ -411,11 +424,13 @@ def get_pcc_application_detail(application_id): # Director selectors # --------------------------------------------------------------------------- -def get_director_new_applications(): +def get_director_new_applications(user=None): """Applications forwarded for Director's review.""" - apps = Application.objects.filter( - status=ApplicationStatus.FORWARDED - ).select_related("primary_applicant") + qs = Application.objects.filter(status=ApplicationStatus.FORWARDED) + if user: + qs = qs.filter(assigned_director=user) + + apps = qs.select_related("primary_applicant") result = {} for a in apps: info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} @@ -429,7 +444,7 @@ def get_director_new_applications(): return result -def get_director_reviewed_applications(): +def get_director_reviewed_applications(user=None): """Applications the director has already acted on.""" statuses = [ ApplicationStatus.APPROVED, @@ -443,7 +458,11 @@ def get_director_reviewed_applications(): ApplicationStatus.PATENT_GRANTED, ApplicationStatus.PATENT_REFUSED, ] - apps = Application.objects.filter(status__in=statuses).select_related("primary_applicant") + qs = Application.objects.filter(status__in=statuses) + if user: + qs = qs.filter(assigned_director=user) + + apps = qs.select_related("primary_applicant") result = {} for a in apps: info = _enrich_applicant_info(a.primary_applicant.user) if a.primary_applicant else {} diff --git a/FusionIIIT/applications/patent_system/services.py b/FusionIIIT/applications/patent_system/services.py index 9eda9583e..3f6178b60 100644 --- a/FusionIIIT/applications/patent_system/services.py +++ b/FusionIIIT/applications/patent_system/services.py @@ -8,12 +8,14 @@ from django.utils.timezone import now from django.contrib.auth.models import User from django.db import transaction, models +from django.shortcuts import get_object_or_404 from .models import ( Application, ApplicationStatus, DecisionStatus, Applicant, Inventor, AuditLog, Budget, BudgetDecision, ApplicationSectionI, ApplicationSectionII, ApplicationSectionIII, - CommunicationLog, AttorneyAssignment, PatentabilityAssessment, + CommunicationLog, CommunicationDirection, ConfidentialityLevel, + AttorneyAssignment, PatentabilityAssessment, FilingRecord, PatentabilityRecommendation, PatentNotification, NotificationType, ApplicationDocument, ) @@ -141,8 +143,8 @@ def _audit(application, user, action, prev="", new="", details=""): ApplicationStatus.APPEAL_UNDER_REVIEW: [ApplicationStatus.APPEAL_APPROVED, ApplicationStatus.APPEAL_REJECTED], ApplicationStatus.APPEAL_APPROVED: [ApplicationStatus.FORWARDED], # Goes back to director review ApplicationStatus.APPEAL_REJECTED: [ApplicationStatus.EXPIRED, ApplicationStatus.WITHDRAWN], - ApplicationStatus.PATENTABILITY_CHECK_STARTED: [ApplicationStatus.PATENTABILITY_CHECK_COMPLETED], - ApplicationStatus.PATENTABILITY_CHECK_COMPLETED: [ApplicationStatus.SEARCH_REPORT_GENERATED], + ApplicationStatus.PATENTABILITY_CHECK_STARTED: [ApplicationStatus.PATENTABILITY_CHECK_COMPLETED, ApplicationStatus.NEEDS_REVISION], + ApplicationStatus.PATENTABILITY_CHECK_COMPLETED: [ApplicationStatus.SEARCH_REPORT_GENERATED, ApplicationStatus.NEEDS_REVISION], ApplicationStatus.SEARCH_REPORT_GENERATED: [ApplicationStatus.PATENT_FILED], ApplicationStatus.PATENT_FILED: [ApplicationStatus.PATENT_PUBLISHED], ApplicationStatus.PATENT_PUBLISHED: [ApplicationStatus.PATENT_GRANTED, ApplicationStatus.PATENT_REFUSED], @@ -356,6 +358,8 @@ def assign_to_director(user, application_id, director_user_id=None): director = User.objects.get(id=director_user_id) except User.DoesNotExist: raise NotFoundError("Director user not found.") + + # pass the User object to the conflict checker _check_director_conflict(director, application) application.assigned_director = director @@ -384,6 +388,9 @@ def director_review(user, application_id, decision, feedback=""): f"Application must be 'Forwarded for Director's Review'. Current: {application.status}" ) + if application.assigned_director and application.assigned_director != user: + raise UnauthorizedError("You are not the assigned director for this application.") + _check_director_conflict(user, application) # CRITICAL VALIDATION: Check inventor requirements before any director decision @@ -535,7 +542,7 @@ def pcc_review_application(user, application_id, comments=""): # ── UC-007: Forward to Director (PCC Admin) ────────────────────────────── @transaction.atomic -def forward_to_director(user, application_id, comments=""): +def forward_to_director(user, application_id, comments="", director_id=None): """PCC Admin forwards a reviewed application to Director.""" assert_pcc_admin(user) try: @@ -561,6 +568,13 @@ def forward_to_director(user, application_id, comments=""): if comments and len(comments) > 1000: raise ValidationError("Comments must be ≤ 1000 characters.") + if director_id: + try: + director_user = User.objects.get(id=director_id) + application.assigned_director = director_user + except User.DoesNotExist: + raise ValidationError("Specified Director does not exist.") + prev = application.status application.status = ApplicationStatus.FORWARDED application.forwarded_to_director_date = now() @@ -1552,3 +1566,159 @@ def get_analytics_summary(year=None, department=None): ], "department_distribution": dept_dist, } + + +# --------------------------------------------------------------------------- +# UC-011: Receive & Respond to Office Actions +# --------------------------------------------------------------------------- + +@transaction.atomic +def record_office_action(user, application_id, data, attachment=None): + """ + PCC Admin logs a new Office Action received from the Patent Office. + We don't have an OfficeAction model, so we use CommunicationLog + and set the application status to NEEDS_REVISION. + """ + assert_pcc_admin(user) + app = get_object_or_404(Application, id=application_id) + + subject = data.get("subject", "Office Action Received") + body = data.get("body", "") + deadline = data.get("deadline_date") + + comm = CommunicationLog.objects.create( + application=app, + logged_by=user, + direction=CommunicationDirection.INCOMING, + external_party_name="Patent Office", + subject=f"[Office Action] {subject}", + body=body, + attachment=attachment, + confidentiality_level=ConfidentialityLevel.CONFIDENTIAL + ) + + _audit(app, user, "Recieved Office Action", prev=app.status, new=ApplicationStatus.NEEDS_REVISION, details=subject) + + app.status = ApplicationStatus.NEEDS_REVISION + app.resubmission_deadline = deadline if deadline else (now() + timedelta(days=60)) + app.save() + + PatentNotification.objects.create( + recipient=app.primary_applicant.user, + application=app, + notification_type=NotificationType.ACTION_REQUIRED, + title="Office Action Received: Revision Required", + message=f"The patent office has issued an objection/requirement. Deadline: {app.resubmission_deadline.strftime('%Y-%m-%d')}.", + deadline_date=app.resubmission_deadline + ) + return comm + +@transaction.atomic +def submit_office_action_response(user, application_id, data, attachment=None): + """ + Applicant provides materials to respond to the Office Action. + """ + app = get_object_or_404(Application, id=application_id) + assert_applicant(user, app) + + body = data.get("body", "Applicant responded with revisions.") + + comm = CommunicationLog.objects.create( + application=app, + logged_by=user, + direction=CommunicationDirection.OUTGOING, + subject="[Office Action Response] Revisions Submitted", + body=body, + attachment=attachment, + confidentiality_level=ConfidentialityLevel.INTERNAL + ) + + _audit(app, user, "Submitted Office Action Response", prev=app.status, new=ApplicationStatus.RESUBMITTED) + app.status = ApplicationStatus.RESUBMITTED + app.save() + + # Notify PCC Admin + pcc_admins = User.objects.filter(extrainfo__designation__name__icontains="PCC") + for admin in pcc_admins: + PatentNotification.objects.create( + recipient=admin, + application=app, + notification_type=NotificationType.STATUS_CHANGE, + title="Applicant Responded to Office Action", + message=f"Applicant {user.username} has provided revisions for {app.title}." + ) + return comm + + +# --------------------------------------------------------------------------- +# UC-013: Track Post-Grant Maintenance & Renewals +# --------------------------------------------------------------------------- + +@transaction.atomic +def record_maintenance_fee(user, application_id, data, receipt=None): + """ + PCC Admin pays a renewal/maintenance fee. Track via the Budget and CommunicationLog. + """ + assert_pcc_admin(user) + app = get_object_or_404(Application, id=application_id) + amount = float(data.get("amount", 0.0)) + remarks = data.get("remarks", "Maintenance fee / Renewal paid.") + + # Update the Budget's administrative_cost tally + budget, _ = Budget.objects.get_or_create(application=app) + budget.administrative_cost += amount + budget.remarks = f"{budget.remarks}\n[Renewal] Added {amount}: {remarks}" + budget.save() + + comm = CommunicationLog.objects.create( + application=app, + logged_by=user, + direction=CommunicationDirection.OUTGOING, + external_party_name="Patent Office (Renewal)", + subject="[Maintenance/Renewal Fee Provided]", + body=remarks, + attachment=receipt, + confidentiality_level=ConfidentialityLevel.INTERNAL + ) + + _audit(app, user, "Recorded Patent Renewal/Maintenance Fee", details=f"Amount: {amount}. {remarks}") + return comm + + +# --------------------------------------------------------------------------- +# UC-017: Track Licensing & Tech Transfer Requests +# --------------------------------------------------------------------------- + +@transaction.atomic +def record_licensing_interest(user, application_id, data, document=None): + """ + Record that a third party is interested in licensing the patent. + """ + app = get_object_or_404(Application, id=application_id) + + company_name = data.get("company_name", "Unknown Company") + contact_email = data.get("contact_email", "") + terms = data.get("proposed_terms", "Awaiting formal terms.") + + comm = CommunicationLog.objects.create( + application=app, + logged_by=user, + direction=CommunicationDirection.INCOMING, + external_party_name=company_name, + external_party_email=contact_email, + subject=f"[Licensing Interest] {company_name}", + body=f"Proposed Terms/Notes:\n{terms}", + attachment=document, + confidentiality_level=ConfidentialityLevel.CONFIDENTIAL + ) + + _audit(app, user, "Recorded Licensing Inquiry", details=f"Company: {company_name}") + + PatentNotification.objects.create( + recipient=app.primary_applicant.user, + application=app, + notification_type=NotificationType.ACTION_REQUIRED, + title="New Licensing Opportunity", + message=f"{company_name} is interested in licensing your patent {app.title}." + ) + return comm From c36c0d8106d2b732472a82252349dcbb8f71e269 Mon Sep 17 00:00:00 2001 From: Tanaybaviskar Date: Thu, 23 Apr 2026 11:55:03 +0530 Subject: [PATCH 3/4] Add 'REJECTED' status to valid transitions for published patents --- FusionIIIT/applications/patent_system/services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FusionIIIT/applications/patent_system/services.py b/FusionIIIT/applications/patent_system/services.py index 3f6178b60..7272d4daf 100644 --- a/FusionIIIT/applications/patent_system/services.py +++ b/FusionIIIT/applications/patent_system/services.py @@ -147,7 +147,7 @@ def _audit(application, user, action, prev="", new="", details=""): ApplicationStatus.PATENTABILITY_CHECK_COMPLETED: [ApplicationStatus.SEARCH_REPORT_GENERATED, ApplicationStatus.NEEDS_REVISION], ApplicationStatus.SEARCH_REPORT_GENERATED: [ApplicationStatus.PATENT_FILED], ApplicationStatus.PATENT_FILED: [ApplicationStatus.PATENT_PUBLISHED], - ApplicationStatus.PATENT_PUBLISHED: [ApplicationStatus.PATENT_GRANTED, ApplicationStatus.PATENT_REFUSED], + ApplicationStatus.PATENT_PUBLISHED: [ApplicationStatus.PATENT_GRANTED, ApplicationStatus.PATENT_REFUSED, ApplicationStatus.REJECTED], } From a28ac42cde87b2738655a9f134618977bdfe03c9 Mon Sep 17 00:00:00 2001 From: Tanaybaviskar Date: Thu, 23 Apr 2026 13:00:53 +0530 Subject: [PATCH 4/4] Add Designated User Roles and Permissions documentation for Patent Management System --- .../patent_system/Designated_Roles.md | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 FusionIIIT/applications/patent_system/Designated_Roles.md diff --git a/FusionIIIT/applications/patent_system/Designated_Roles.md b/FusionIIIT/applications/patent_system/Designated_Roles.md new file mode 100644 index 000000000..bdbb5073e --- /dev/null +++ b/FusionIIIT/applications/patent_system/Designated_Roles.md @@ -0,0 +1,68 @@ +# Module Name: Patent Management System + +## Designated User Roles & Permissions + +### 1. Role Name: PCC Admin + +* **Description:** Central module administrator responsible for operational control, workflow routing, and compliance handling. + +* **Permissions:** + + * Full CRUD on patent applications and related module records. + + * Review new submissions and forward applications to Director. + + * Request modifications, manage workflow state transitions, and monitor pending actions. + + * Manage budget entries, communication logs, attorney assignment, filing records, and analytics dashboards. + + * Access audit logs and module-wide reporting views. + + +### 2. Role Name: Director + +* **Description:** Decision authority for review, approval/rejection, appeal decisions, and escalated budget approvals. + +* **Permissions:** + + * View applications assigned for Director review. + + * Approve, reject, or mark applications for revision with mandatory feedback. + + * Approve or deny escalated budgets. + + * Review and decide appeals. + + * View notifications and decision-linked records relevant to assigned workflows. + + +### 3. Role Name: Applicant / Inventor + +* **Description:** Primary end-user who submits, tracks, and updates patent applications and inventor consent data. + +* **Permissions:** + + * Create and submit patent applications. + + * View personal/associated applications and status timeline. + + * Revise and resubmit applications when revision is requested. + + * Withdraw applications (subject to workflow/state restrictions). + + * Provide/revoke inventor consent where applicable. + + * Lodge appeal for rejected applications within defined timeline constraints. + + +### 4. Role Name: External Attorney (Recorded Entity) + +* **Description:** External legal expert tracked by the module for legal assessment and filing lifecycle stages. + +* **Permissions:** + + * Not a direct portal-authenticated role in current implementation. + + * Assignment details, legal assessment inputs, and filing details are recorded by PCC Admin. + + * Actions are represented through module records (assessment, filing, communication logs).