diff --git a/machine/engine.py b/machine/engine.py index fb83abd..aa0f7fc 100644 --- a/machine/engine.py +++ b/machine/engine.py @@ -168,6 +168,7 @@ async def evaluate( sources: dict[str, pd.DataFrame] | None = None, calculation_date=None, requested_output: str | None = None, + approved: bool = False, ) -> dict[str, Any]: """Evaluate rules using service context and sources""" parameters = parameters or {} @@ -181,7 +182,9 @@ async def evaluate( claims = None if "BSN" in parameters: bsn = parameters["BSN"] - claims = self.service_provider.claim_manager.get_claim_by_bsn_service_law(bsn, self.service_name, self.law) + claims = self.service_provider.claim_manager.get_claim_by_bsn_service_law( + bsn, self.service_name, self.law, approved=approved + ) context = RuleContext( definitions=self.definitions, diff --git a/machine/events/case/aggregate.py b/machine/events/case/aggregate.py index 1569e7a..76937d2 100644 --- a/machine/events/case/aggregate.py +++ b/machine/events/case/aggregate.py @@ -11,7 +11,7 @@ class CaseStatus(str, Enum): OBJECTED = "OBJECTED" -class ClaimStatusTranscoding(Transcoding): +class CaseStatusTranscoding(Transcoding): @staticmethod def can_handle(obj: object) -> bool: return isinstance(obj, CaseStatus | str) @@ -29,7 +29,7 @@ def decode(data: str) -> str: return data # Keep it as a string -Transcoding.register(ClaimStatusTranscoding) +Transcoding.register(CaseStatusTranscoding) class Case(Aggregate): @@ -43,6 +43,7 @@ def __init__( claimed_result: dict, rulespec_uuid: str, ) -> None: + self.claim_ids = None self.bsn = bsn self.service = service_type self.law = law @@ -190,3 +191,26 @@ def can_appeal(self) -> bool: if not hasattr(self, "appeal_status") or self.appeal_status is None: return False return bool(self.appeal_status.get("possible", False)) + + @event("ClaimCreated") + def add_claim(self, claim_id: str) -> None: + """Record when a new claim is created for this case""" + if not hasattr(self, "claim_ids") or self.claim_ids is None: + self.claim_ids = set() + self.claim_ids.add(claim_id) + + @event("ClaimApproved") + def approve_claim(self, claim_id: str) -> None: + """Record when a claim is approved""" + if not hasattr(self, "claim_ids") or self.claim_ids is None: + self.claim_ids = set() + if claim_id not in self.claim_ids: + self.claim_ids.add(claim_id) + + @event("ClaimRejected") + def reject_claim(self, claim_id: str) -> None: + """Record when a claim is rejected""" + if not hasattr(self, "claim_ids") or self.claim_ids is None: + self.claim_ids = set() + if claim_id not in self.claim_ids: + self.claim_ids.add(claim_id) diff --git a/machine/events/claim/aggregate.py b/machine/events/claim/aggregate.py index 721c0fd..e37ada0 100644 --- a/machine/events/claim/aggregate.py +++ b/machine/events/claim/aggregate.py @@ -57,11 +57,44 @@ def __init__( self.status = ClaimStatus.PENDING self.created_at = datetime.now() + @event("Reset") + def reset( + self, + service: str, + key: str, + new_value: Any, + reason: str, + claimant: str, + law: str, + bsn: str, + case_id: str | None = None, + old_value: Any | None = None, + evidence_path: str | None = None, + ) -> None: + self.service = service + self.key = key + self.old_value = old_value + self.new_value = new_value + self.reason = reason + self.evidence_path = evidence_path + self.claimant = claimant + self.case_id = case_id + self.law = law + self.bsn = bsn + self.status = ClaimStatus.PENDING + self.created_at = datetime.now() + + @event("AutoApproved") + def auto_approve(self, verified_by: str, verified_value: Any) -> None: + """Approve the claim with potentially adjusted value""" + self.status = ClaimStatus.APPROVED + self.verified_by = verified_by + self.verified_value = verified_value + self.verified_at = datetime.now() + @event("Approved") def approve(self, verified_by: str, verified_value: Any) -> None: """Approve the claim with potentially adjusted value""" - if self.status != ClaimStatus.PENDING: - raise ValueError("Can only approve pending claims") self.status = ClaimStatus.APPROVED self.verified_by = verified_by self.verified_value = verified_value @@ -70,8 +103,6 @@ def approve(self, verified_by: str, verified_value: Any) -> None: @event("Rejected") def reject(self, rejected_by: str, rejection_reason: str) -> None: """Reject the claim with a reason""" - if self.status != ClaimStatus.PENDING: - raise ValueError("Can only reject pending claims") self.status = ClaimStatus.REJECTED self.rejected_by = rejected_by self.rejection_reason = rejection_reason diff --git a/machine/events/claim/application.py b/machine/events/claim/application.py index 10d5d39..3f52177 100644 --- a/machine/events/claim/application.py +++ b/machine/events/claim/application.py @@ -19,8 +19,10 @@ def __init__(self, rules_engine, **kwargs) -> None: self._service_index: dict[str, list[str]] = {} # service -> [claim_ids] self._case_index: dict[str, list[str]] = {} # case_id -> [claim_ids] self._claimant_index: dict[str, list[str]] = {} # claimant -> [claim_ids] + self._bsn_index: dict[str, list[str]] = {} # claimant -> [claim_ids] self._status_index: dict[ClaimStatus, list[str]] = {status: [] for status in ClaimStatus} self._bsn_service_law_index: dict[tuple[str, str, str], dict[str, str]] = {} # (service, key) -> claim_id + self._case_manager = None def _index_claim(self, claim: Claim) -> None: """Add claim to all indexes""" @@ -42,19 +44,19 @@ def _index_claim(self, claim: Claim) -> None: self._claimant_index[claim.claimant] = [] self._claimant_index[claim.claimant].append(claim_id) - # Status index - self._status_index[claim.status].append(claim_id) + # BSN index + if claim.bsn not in self._bsn_index: + self._bsn_index[claim.bsn] = [] + self._bsn_index[claim.bsn].append(claim_id) # Service-Key index if (claim.bsn, claim.service, claim.law) not in self._bsn_service_law_index: self._bsn_service_law_index[(claim.bsn, claim.service, claim.law)] = {} self._bsn_service_law_index[(claim.bsn, claim.service, claim.law)][claim.key] = claim_id - def _update_status_index(self, claim: Claim, old_status: ClaimStatus) -> None: - """Update status index when claim status changes""" - claim_id = str(claim.id) - self._status_index[old_status].remove(claim_id) - self._status_index[claim.status].append(claim_id) + @property + def case_manager(self): + return self._case_manager def submit_claim( self, @@ -68,42 +70,84 @@ def submit_claim( case_id: str | None = None, old_value: Any | None = None, evidence_path: str | None = None, + auto_approve: bool = False, # Add this parameter ) -> str: """ Submit a new claim. Can be linked to an existing case or standalone. + If auto_approve is True, the claim will be automatically approved. """ - claim = Claim( - service=service, - key=key, - new_value=new_value, - reason=reason, - claimant=claimant, - case_id=case_id, - old_value=old_value, - evidence_path=evidence_path, - law=law, - bsn=bsn, - ) + existing_claims = self.get_claim_by_bsn_service_law(bsn, service, law, include_rejected=True) + if existing_claims and key in existing_claims: + claim = existing_claims[key] + claim.reset( + service=service, + key=key, + new_value=new_value, + reason=reason, + claimant=claimant, + case_id=case_id, + old_value=old_value, + evidence_path=evidence_path, + law=law, + bsn=bsn, + ) + self.save(claim) + else: + claim = Claim( + service=service, + key=key, + new_value=new_value, + reason=reason, + claimant=claimant, + case_id=case_id, + old_value=old_value, + evidence_path=evidence_path, + law=law, + bsn=bsn, + ) + self.save(claim) + self._index_claim(claim) + + case = None + if claim.case_id: + case = self.case_manager.get_case_by_id(claim.case_id) + if case: + case.add_claim(claim.id) + self.case_manager.save(case) + + # Auto-approve if requested + if auto_approve: + claim.auto_approve(verified_by=claimant, verified_value=new_value) + self.save(claim) + if case: + case.approve_claim(claim.id) + self.case_manager.save(case) - self.save(claim) - self._index_claim(claim) return str(claim.id) def approve_claim(self, claim_id: str, verified_by: str, verified_value: Any) -> None: """Approve a claim with verified value""" claim = self.get_claim(claim_id) - old_status = claim.status claim.approve(verified_by, verified_value) self.save(claim) - self._update_status_index(claim, old_status) + + if claim.case_id: + case = self.case_manager.get_case_by_id(claim.case_id) + if case: + case.approve_claim(claim.id) + self.case_manager.save(case) def reject_claim(self, claim_id: str, rejected_by: str, rejection_reason: str) -> None: """Reject a claim with reason""" claim = self.get_claim(claim_id) - old_status = claim.status claim.reject(rejected_by, rejection_reason) self.save(claim) - self._update_status_index(claim, old_status) + + if claim.case_id: + case = self.case_manager.get_case_by_id(claim.case_id) + if case: + case.reject_claim(claim.id) + self.case_manager.save(case) def link_case(self, claim_id: str, case_id: str) -> None: """Link an existing claim to a case""" @@ -127,27 +171,67 @@ def get_claim(self, claim_id: str) -> Claim: """Get claim by ID""" return self.repository.get(UUID(claim_id)) - def get_claims_by_service(self, service: str) -> list[Claim]: - """Get all claims for a service""" - return [self.get_claim(claim_id) for claim_id in self._service_index.get(service, [])] + @staticmethod + def _filter_claims_by_status(claims: list[Claim], approved: bool, include_rejected: bool = False) -> list[Claim]: + """ + Helper method to filter claims based on approved parameter. - def get_claims_by_case(self, case_id: str) -> list[Claim]: - """Get all claims for a case""" - return [self.get_claim(claim_id) for claim_id in self._case_index.get(case_id, [])] + Args: + claims: List of claims to filter + approved: If True, only return approved claims. If False, return approved and submitted claims. + include_rejected: If True, also include rejected claims in the results. + """ + if approved: + return [claim for claim in claims if claim.status == ClaimStatus.APPROVED] - def get_claims_by_claimant(self, claimant: str) -> list[Claim]: - """Get all claims made by a claimant""" - return [self.get_claim(claim_id) for claim_id in self._claimant_index.get(claimant, [])] + allowed_statuses = {ClaimStatus.APPROVED, ClaimStatus.PENDING} + if include_rejected: + allowed_statuses.add(ClaimStatus.REJECTED) - def get_claims_by_status(self, status: ClaimStatus) -> list[Claim]: - """Get all claims with a specific status""" - return [self.get_claim(claim_id) for claim_id in self._status_index.get(status, [])] + return [claim for claim in claims if claim.status in allowed_statuses] - def get_claim_by_bsn_service_law(self, bsn: str, service: str, law: str) -> dict[str:Claim] | None: + def get_claims_by_service( + self, service: str, approved: bool = False, include_rejected: bool = False + ) -> list[Claim]: """ - Get a dictionary with claims + Get all claims for a service, filtered by status + + Args: + service: Service to filter by + approved: If True, only return approved claims + include_rejected: If True, also include rejected claims """ + claims = [self.get_claim(claim_id) for claim_id in self._service_index.get(service, [])] + return self._filter_claims_by_status(claims, approved, include_rejected) + + def get_claims_by_case(self, case_id: str, approved: bool = False, include_rejected: bool = False) -> list[Claim]: + """Get all claims for a case, filtered by status""" + claims = [self.get_claim(claim_id) for claim_id in self._case_index.get(case_id, [])] + return self._filter_claims_by_status(claims, approved, include_rejected) + + def get_claims_by_claimant( + self, claimant: str, approved: bool = False, include_rejected: bool = False + ) -> list[Claim]: + """Get all claims made by a claimant, filtered by status""" + claims = [self.get_claim(claim_id) for claim_id in self._claimant_index.get(claimant, [])] + return self._filter_claims_by_status(claims, approved, include_rejected) + + def get_claims_by_bsn(self, bsn: str, approved: bool = False, include_rejected: bool = False) -> list[Claim]: + """Get all claims for a BSN, filtered by status""" + claims = [self.get_claim(claim_id) for claim_id in self._bsn_index.get(bsn, [])] + return self._filter_claims_by_status(claims, approved, include_rejected) + + def get_claim_by_bsn_service_law( + self, bsn: str, service: str, law: str, approved: bool = False, include_rejected: bool = False + ) -> dict[str:Claim] | None: + """Get a dictionary with claims filtered by status""" key_index = self._bsn_service_law_index.get((bsn, service, law)) - if key_index: - return {key: self.get_claim(claim_id) for key, claim_id in key_index.items()} - return None + if not key_index: + return None + claims = {key: self.get_claim(claim_id) for key, claim_id in key_index.items()} + filtered_claims = { + key: claim + for key, claim in claims.items() + if claim in self._filter_claims_by_status([claim], approved, include_rejected) + } + return filtered_claims if filtered_claims else None diff --git a/machine/events/claim/processor.py b/machine/events/claim/processor.py index 6051f6f..f1ffee5 100644 --- a/machine/events/claim/processor.py +++ b/machine/events/claim/processor.py @@ -1,8 +1,6 @@ from eventsourcing.dispatch import singledispatchmethod from eventsourcing.system import ProcessApplication -from .aggregate import Claim - class ClaimProcessor(ProcessApplication): """Process application for handling claim events""" @@ -22,23 +20,3 @@ def case_manager(self): @singledispatchmethod def policy(self, domain_event, process_event) -> None: """Sync policy that processes events""" - - @policy.register(Claim.Approved) - async def handle_claim_approved(self, domain_event, process_event) -> None: - """ - When a claim is approved: - 1. If linked to a case, update the case - 2. Run any applicable rules - """ - claim = self.repository.get(domain_event.originator_id) - - # If claim is linked to a case, update it - if claim.case_id: - case = self.case_manager.get_case_by_id(claim.case_id) - if case: - # Update the case parameter - case.update_parameter(key=claim.key, new_value=domain_event.verified_value) - self.case_manager.save(case) - - # Run any applicable rules - await self.rules_engine.apply_rules(domain_event) diff --git a/machine/service.py b/machine/service.py index 16a3a3b..f286562 100644 --- a/machine/service.py +++ b/machine/service.py @@ -80,6 +80,7 @@ async def evaluate( parameters: dict[str, Any], overwrite_input: dict[str, Any] | None = None, requested_output: str | None = None, + approved: bool = False, ) -> RuleResult: """ Evaluate rules for given law and reference date @@ -101,6 +102,7 @@ async def evaluate( sources=self.source_dataframes, calculation_date=reference_date, requested_output=requested_output, + approved=approved, ) return RuleResult.from_engine_result(result, engine.spec.get("uuid")) @@ -161,6 +163,8 @@ def __init__(self, env=None, **kwargs) -> None: self.case_manager = self.runner.get(WrappedCaseManager) self.claim_manager = self.runner.get(WrappedClaimManager) + self.claim_manager._case_manager = self.case_manager + def __exit__(self): self.runner.stop() @@ -179,6 +183,7 @@ async def evaluate( reference_date: str | None = None, overwrite_input: dict[str, Any] | None = None, requested_output: str | None = None, + approved: bool = False, ) -> RuleResult: reference_date = reference_date or self.root_reference_date with logger.indent_block( @@ -191,6 +196,7 @@ async def evaluate( parameters=parameters, overwrite_input=overwrite_input, requested_output=requested_output, + approved=approved, ) async def apply_rules(self, event) -> None: diff --git a/web/routers/admin.py b/web/routers/admin.py index 0c59482..2184a6f 100644 --- a/web/routers/admin.py +++ b/web/routers/admin.py @@ -173,4 +173,33 @@ async def view_case(request: Request, case_id: str, services: Services = Depends case.events = services.case_manager.get_events(case.id) law, result, rule_spec = await evaluate_law(case.bsn, case.law, case.service, services) flat_path = flatten_path_nodes(result.path) - return templates.TemplateResponse("admin/case_detail.html", {"request": request, "case": case, "path": flat_path}) + claims = services.claim_manager.get_claims_by_bsn(case.bsn, include_rejected=True) + claim_ids = {claim.id: claim for claim in claims} + claim_map = {(claim.service, claim.law, claim.key): claim for claim in claims} + return templates.TemplateResponse( + "admin/case_detail.html", + { + "request": request, + "case": case, + "path": flat_path, + "claim_map": claim_map, + "claim_ids": claim_ids, + }, + ) + + +@router.get("/claims/{claim_id}") +async def view_claim(request: Request, claim_id: str, services: Services = Depends(get_services)): + """View details of a specific claim""" + claim = services.claim_manager.get_claim(claim_id) + if not claim: + raise HTTPException(status_code=404, detail="Claim not found") + + # Get related case if it exists + related_case = None + if claim.case_id: + related_case = services.case_manager.get_case_by_id(claim.case_id) + + return templates.TemplateResponse( + "admin/claim_detail.html", {"request": request, "claim": claim, "related_case": related_case} + ) diff --git a/web/routers/edit.py b/web/routers/edit.py index 2bc681c..698f0fb 100644 --- a/web/routers/edit.py +++ b/web/routers/edit.py @@ -1,6 +1,6 @@ import json -from fastapi import APIRouter, Depends, File, Form, Request, UploadFile +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import HTMLResponse from machine.service import Services @@ -18,6 +18,8 @@ async def get_edit_form( value: str, law: str, bsn: str, + show_approve: bool = False, + services: Services = Depends(get_services), ): """Return the edit form HTML""" try: @@ -25,6 +27,25 @@ async def get_edit_form( except json.JSONDecodeError: parsed_value = value + # Try to get existing claim by bsn, service, law and key + claim_data = None + existing_claims = services.claim_manager.get_claim_by_bsn_service_law( + bsn=bsn, + service=service, + law=law, + include_rejected=True, # Include rejected claims to show history + ) + + if existing_claims and key in existing_claims: + claim = existing_claims[key] + claim_data = { + "new_value": claim.new_value, + "reason": claim.reason, + "evidence_path": claim.evidence_path, + "auto_approve": claim.status == "APPROVED", + "status": claim.status, + } + return templates.TemplateResponse( "partials/edit_form.html", { @@ -35,6 +56,8 @@ async def get_edit_form( "value": parsed_value, "law": law, "bsn": bsn, + "show_approve": show_approve, + "claim_data": claim_data, }, ) @@ -50,6 +73,8 @@ async def update_value( law: str = Form(...), bsn: str = Form(...), evidence: UploadFile = File(None), + claimant: str = Form(...), # Add this + auto_approve: bool = Form(False), # Add this services: Services = Depends(get_services), ): """Handle the value update by creating a claim""" @@ -86,11 +111,12 @@ async def update_value( key=key, new_value=parsed_value, reason=reason, - claimant=None, + claimant=claimant, case_id=case_id, evidence_path=evidence_path, law=law, bsn=bsn, + auto_approve=auto_approve, ) response = templates.TemplateResponse( @@ -99,3 +125,61 @@ async def update_value( ) response.headers["HX-Trigger"] = "edit-dialog-closed" return response + + +@router.post("/reject-claim", response_class=HTMLResponse) +async def reject_claim( + request: Request, + claim_id: str = Form(...), + reason: str = Form(...), + services: Services = Depends(get_services), +): + """Handle dropping a claim by rejecting it""" + try: + services.claim_manager.reject_claim( + claim_id=claim_id, + rejected_by="USER", # You might want to get this from auth + rejection_reason=f"Claim dropped: {reason}", + ) + + response = templates.TemplateResponse("partials/claim_dropped.html", {"request": request}) + response.headers["HX-Trigger"] = "edit-dialog-closed" + return response + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/reject-claim-form", response_class=HTMLResponse) +async def get_reject_claim_form( + request: Request, + claim_id: str, +): + """Return the drop claim form HTML""" + return templates.TemplateResponse( + "partials/reject-claim-form.html", + { + "request": request, + "claim_id": claim_id, + }, + ) + + +@router.post("/approve-claim", response_class=HTMLResponse) +async def approve_claim( + request: Request, + claim_id: str = Form(...), + services: Services = Depends(get_services), +): + """Handle approving a claim by verifying it with its original new_value""" + try: + services.claim_manager.approve_claim( + claim_id=claim_id, + verified_by="USER", + verified_value=None, + ) + + response = templates.TemplateResponse("partials/claim_approved.html", {"request": request}) + response.headers["HX-Trigger"] = "edit-dialog-closed" + return response + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) diff --git a/web/routers/laws.py b/web/routers/laws.py index e91ccce..f11629f 100644 --- a/web/routers/laws.py +++ b/web/routers/laws.py @@ -47,7 +47,7 @@ async def evaluate_law(bsn: str, law: str, service: str, services: Services): services.set_source_dataframe(service_name, table_name, df) # Execute the law - result = await services.evaluate(service, law=law, parameters={"BSN": bsn}, reference_date=TODAY) + result = await services.evaluate(service, law=law, parameters={"BSN": bsn}, reference_date=TODAY, approved=True) return law, result, rule_spec @@ -206,6 +206,9 @@ async def explain_panel( flat_path = flatten_path_nodes(result.path) existing_case = services.case_manager.get_case(bsn, service, law) + claims = services.claim_manager.get_claims_by_bsn(bsn, include_rejected=True) + claim_map = {(claim.service, claim.law, claim.key): claim for claim in claims} + return templates.TemplateResponse( "partials/tiles/components/explanation_panel.html", { @@ -219,6 +222,7 @@ async def explain_panel( "path": flat_path, "bsn": bsn, "current_case": existing_case, + "claim_map": claim_map, }, ) except Exception as e: diff --git a/web/templates/admin/case_detail.html b/web/templates/admin/case_detail.html index 6441971..73b0dfa 100644 --- a/web/templates/admin/case_detail.html +++ b/web/templates/admin/case_detail.html @@ -202,10 +202,89 @@
{{ claim.reason }}
+Nieuwe waarde
+{{ claim.new_value }}
+Geverifieerde waarde
+{{ claim.verified_value }}
+Geen claims voor deze zaak
+ {% endif %} +BSN
+{{ claim.bsn }}
+Dienst
+{{ claim.service }}
+Wet
+{{ claim.law }}
+Status
+{{ claim.status }}
+Aanvrager
+{{ claim.claimant }}
+Aangemaakt op
+{{ claim.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
+Parameter
+{{ claim.key }}
+Oude Waarde
+{{ claim.old_value }}
+Nieuwe Waarde
+{{ claim.new_value }}
+Reden
+{{ claim.reason }}
+Geverifieerd door
+{{ claim.verified_by }}
+Geverifieerd op
+{{ claim.verified_at.strftime('%Y-%m-%d %H:%M:%S') }}
+Geverifieerde Waarde
+{{ claim.verified_value }}
+Afgewezen door
+{{ claim.rejected_by }}
+Afgewezen op
+{{ claim.rejected_at.strftime('%Y-%m-%d %H:%M:%S') }}
+Reden voor afwijzing
+{{ claim.rejection_reason }}
+Bewijsmateriaal Locatie
+{{ claim.evidence_path }}
+Zaak ID
+ +Zaak Status
+{{ related_case.status }}
+