Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/country_workspace/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from ..cache.smart_panel import panel_cache
from .batch import BatchAdmin # noqa
from .beneficiary_group import BeneficiaryGroupAdmin # noqa
from .constance import ConstanceAdmin # noqa
from .household import HouseholdAdmin # noqa
from .individual import IndividualAdmin # noqa
Expand Down
27 changes: 27 additions & 0 deletions src/country_workspace/admin/beneficiary_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from django.contrib import admin
from django.http import HttpRequest

from country_workspace.models import BeneficiaryGroup
from .base import BaseModelAdmin


@admin.register(BeneficiaryGroup)
class BeneficiaryGroupAdmin(BaseModelAdmin):
list_display = ("name", "group_label", "group_label_plural", "member_label", "member_label_plural", "master_detail")
search_fields = (
"name",
"group_label",
"group_label_plural",
"member_label",
"member_label_plural",
)
ordering = ("name",)

def has_add_permission(self, request: HttpRequest) -> bool:
return False

def has_delete_permission(self, request: HttpRequest, obj: BeneficiaryGroup = None) -> bool:
return False

def has_change_permission(self, request: HttpRequest, obj: BeneficiaryGroup = None) -> bool:
return False
2 changes: 2 additions & 0 deletions src/country_workspace/admin/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class ProgramAdmin(BaseModelAdmin):
list_display = (
"name",
"sector",
"beneficiary_group",
"status",
"active",
"beneficiary_validator",
Expand All @@ -32,6 +33,7 @@ class ProgramAdmin(BaseModelAdmin):
"status",
"active",
"sector",
"beneficiary_group",
"beneficiary_validator",
"household_checker",
"individual_checker",
Expand Down
30 changes: 28 additions & 2 deletions src/country_workspace/contrib/hope/sync/office.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from django.core.cache import cache
from hope_flex_fields.models import DataChecker

from country_workspace.models import Office, Program, SyncLog
from country_workspace.models import BeneficiaryGroup, Office, Program, SyncLog

from .. import constants
from ..client import HopeClient
Expand All @@ -30,10 +30,11 @@ def sync_offices(stdout: TextIOBase | None = None) -> dict[str, int]:
)
totals["add" if created else "upd"] += 1
SyncLog.objects.register_sync(Office)
return totals
return totals


def sync_programs(limit_to_office: "Office | None" = None, stdout: TextIOBase | None = None) -> dict[str, int]:
sync_beneficiary_groups(stdout=stdout)
if stdout:
stdout.write("Fetching Programs data from HOPE...")
client = HopeClient()
Expand All @@ -50,6 +51,7 @@ def sync_programs(limit_to_office: "Office | None" = None, stdout: TextIOBase |
office = Office.objects.get(code=record["business_area_code"])
if record["status"] not in [Program.ACTIVE, Program.DRAFT]:
continue
beneficiary_group = BeneficiaryGroup.objects.get(hope_id=record["beneficiary_group"])
p, created = Program.objects.get_or_create(
hope_id=record["id"],
defaults={
Expand All @@ -58,6 +60,7 @@ def sync_programs(limit_to_office: "Office | None" = None, stdout: TextIOBase |
"status": record["status"],
"sector": record["sector"],
"country_office": office,
"beneficiary_group": beneficiary_group,
},
)
if created:
Expand All @@ -73,6 +76,29 @@ def sync_programs(limit_to_office: "Office | None" = None, stdout: TextIOBase |
return totals


def sync_beneficiary_groups(stdout: TextIOBase | None = None) -> bool:
totals = {"add": 0, "upd": 0}
client = HopeClient()
if stdout:
stdout.write("Fetching Beneficiary Groups data from HOPE...")
with cache.lock("sync-beneficiary-groups"):
for record in client.get("beneficiary-groups"):
__, created = BeneficiaryGroup.objects.get_or_create(
hope_id=record["id"],
defaults={
"name": record["name"],
"group_label": record["group_label"],
"group_label_plural": record["group_label_plural"],
"member_label": record["member_label"],
"member_label_plural": record["member_label_plural"],
"master_detail": record["master_detail"],
},
)
totals["add" if created else "upd"] += 1
SyncLog.objects.register_sync(BeneficiaryGroup)
return totals


def sync_all(stdout: TextIOBase | None = None) -> bool:
sync_offices(stdout=stdout)
sync_programs(stdout=stdout)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Generated by Django 5.1.7 on 2025-03-25 13:44

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("country_workspace", "0008_alter_individual_options"),
]

operations = [
migrations.CreateModel(
name="BeneficiaryGroup",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
(
"hope_id",
models.CharField(
blank=True, help_text="Unique ID from HOPE", max_length=100, null=True, unique=True
),
),
("name", models.CharField(help_text="Name of the beneficiary group", max_length=255, unique=True)),
("group_label", models.CharField(help_text="Label for the group", max_length=255)),
("group_label_plural", models.CharField(help_text="Plural label for the group", max_length=255)),
("member_label", models.CharField(help_text="Label for the member", max_length=255)),
("member_label_plural", models.CharField(help_text="Plural label for the member", max_length=255)),
(
"master_detail",
models.BooleanField(default=True, help_text="Indicates if this is a master-detail group"),
),
],
options={
"verbose_name": "Beneficiary Group",
"verbose_name_plural": "Beneficiary Groups",
"ordering": ("name",),
},
),
migrations.AlterModelOptions(
name="asyncjob",
options={
"permissions": (("debug_job", "Can debug background jobs"),),
"verbose_name": "Async Job",
"verbose_name_plural": "Async Jobs",
},
),
migrations.AlterModelOptions(
name="batch",
options={"verbose_name": "Batch", "verbose_name_plural": "Batches"},
),
migrations.AlterField(
model_name="office",
name="kobo_country_code",
field=models.CharField(blank=True, max_length=3, null=True),
),
migrations.AddField(
model_name="program",
name="beneficiary_group",
field=models.ForeignKey(
blank=True,
help_text="Beneficiary group to which this program belongs",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="programs",
to="country_workspace.beneficiarygroup",
),
),
]
1 change: 1 addition & 0 deletions src/country_workspace/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from .batch import Batch # noqa
from .beneficiary_group import BeneficiaryGroup # noqa
from .household import Household # noqa
from .individual import Individual # noqa
from .jobs import AsyncJob # noqa
Expand Down
3 changes: 3 additions & 0 deletions src/country_workspace/models/batch.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.db import models
from django.utils.translation import gettext as _

from .base import BaseModel
from .user import User
Expand All @@ -19,6 +20,8 @@ class BatchSource(models.TextChoices):

class Meta:
unique_together = (("import_date", "name"),)
verbose_name = _("Batch")
verbose_name_plural = _("Batches")

def __str__(self) -> str:
return self.name or f"Batch self.pk ({self.country_office})"
19 changes: 19 additions & 0 deletions src/country_workspace/models/beneficiary_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from django.db import models


class BeneficiaryGroup(models.Model):
hope_id = models.CharField(max_length=100, blank=True, null=True, unique=True, help_text="Unique ID from HOPE")
name = models.CharField(max_length=255, unique=True, help_text="Name of the beneficiary group")
group_label = models.CharField(max_length=255, help_text="Label for the group")
group_label_plural = models.CharField(max_length=255, help_text="Plural label for the group")
member_label = models.CharField(max_length=255, help_text="Label for the member")
member_label_plural = models.CharField(max_length=255, help_text="Plural label for the member")
master_detail = models.BooleanField(default=True, help_text="Indicates if this is a master-detail group")

class Meta:
verbose_name = "Beneficiary Group"
verbose_name_plural = "Beneficiary Groups"
ordering = ("name",)

def __str__(self) -> str:
return self.name
2 changes: 2 additions & 0 deletions src/country_workspace/models/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class JobType(models.TextChoices):

class Meta:
permissions = (("debug_job", "Can debug background jobs"),)
verbose_name = "Async Job"
verbose_name_plural = "Async Jobs"

def __str__(self) -> str:
return self.description or f"Background Job #{self.pk}"
Expand Down
9 changes: 9 additions & 0 deletions src/country_workspace/models/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from strategy_field.utils import fqn

from country_workspace.models.office import Office
from country_workspace.models.beneficiary_group import BeneficiaryGroup

from ..validators.registry import NoopValidator, beneficiary_validator_registry
from .base import BaseModel, Validable
Expand Down Expand Up @@ -44,6 +45,14 @@ class Program(BaseModel):
(WASH, _("WASH")),
)
hope_id = models.CharField(max_length=200, unique=True, editable=False)
beneficiary_group = models.ForeignKey(
BeneficiaryGroup,
on_delete=models.PROTECT,
related_name="programs",
null=True,
blank=True,
help_text="Beneficiary group to which this program belongs",
)
country_office = models.ForeignKey(Office, on_delete=models.CASCADE, related_name="programs")
name = models.CharField(max_length=255)
code = models.CharField(max_length=255, blank=True, null=True)
Expand Down
2 changes: 2 additions & 0 deletions src/country_workspace/workspaces/admin/program.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ def _configure_columns(
@button(
permission="workspaces.change_countryprogram",
html_attrs={"title": "Allow to select columns to be highlighted in the list view."},
visible=lambda btn: btn.context["original"].beneficiary_group.master_detail,
enabled=lambda btn: btn.context["original"].beneficiary_group.master_detail,
)
def household_columns(self, request: HttpResponse, pk: str) -> "HttpResponse | HttpResponseRedirect":
context = self.get_common_context(request, pk, title="Configure default Household columns")
Expand Down
100 changes: 97 additions & 3 deletions src/country_workspace/workspaces/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from django.template.response import TemplateResponse
from django.urls import NoReverseMatch, URLPattern, URLResolver, reverse
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy
from django.utils.translation import gettext_lazy as _
from django.views import View
from smart_admin.autocomplete import SmartAutocompleteJsonView

Expand Down Expand Up @@ -119,9 +119,9 @@ class TenantAdminSite(admin.AdminSite):
password_change_template = None
password_change_done_template = None

site_title = gettext_lazy("HOPE Country Workspace site admin")
site_title = _("HOPE Country Workspace site admin")
site_header = "Country Workspace"
index_title = gettext_lazy("")
index_title = _("")
login_form = TenantAuthenticationForm

namespace = "workspace"
Expand Down Expand Up @@ -183,6 +183,99 @@ def _build_app_dict(self, request: HttpRequest, label: str | None = None) -> dic

return app_dict

def get_menu_items(self, request: "HttpRequest") -> list[dict[str, Any]]:
"""Return a simplified list of menu items based on the current program."""
items = [
{
"name": _("Home"),
"url": reverse("workspace:index"),
"icon": "icon-home",
"selected": not hasattr(self, "modeladmin_name"),
},
]

program = get_selected_program()
if program:
bg = program.beneficiary_group
items.extend(
[
{
"name": program._meta.verbose_name_plural,
"url": reverse("workspace:workspaces_countryprogram_change", args=[program.pk]),
"icon": "icon-equalizer",
"selected": getattr(self, "modeladmin_name", None) == "CountryProgramAdmin",
},
]
)

if bg and bg.master_detail:
items.append(
{
"name": bg.group_label_plural,
"url": reverse("workspace:workspaces_countryhousehold_changelist"),
"icon": "icon-members",
"selected": getattr(self, "modeladmin_name", None) == "CountryHouseholdAdmin",
}
)
items.append(
{
"name": bg.member_label_plural,
"url": reverse("workspace:workspaces_countryindividual_changelist"),
"icon": "icon-user",
"selected": getattr(self, "modeladmin_name", None) == "CountryIndividualAdmin",
"indent": True,
}
)
elif bg:
items.append(
{
"name": bg.member_label_plural,
"url": reverse("workspace:workspaces_countryindividual_changelist"),
"icon": "icon-user",
"selected": getattr(self, "modeladmin_name", None) == "CountryIndividualAdmin",
}
)

items.extend(
[
{
"name": apps.get_model("country_workspace", "Batch")._meta.verbose_name_plural,
"url": reverse("workspace:workspaces_countrybatch_changelist"),
"icon": "icon-sign",
"selected": getattr(self, "modeladmin_name", None) == "CountryBatchAdmin",
},
{
"name": apps.get_model("country_workspace", "AsyncJob")._meta.verbose_name_plural,
"url": reverse("workspace:workspaces_countryasyncjob_changelist"),
"icon": "icon-globe",
"selected": getattr(self, "modeladmin_name", None) == "CountryJobAdmin",
},
]
)

items.append(
{
"name": _("Logout"),
"url": reverse("admin:logout"),
"icon": "icon-logout",
"selected": False,
"is_form": True,
}
)

if request.user.is_staff:
items.append(
{
"name": _("Admin"),
"url": reverse("admin:index"),
"icon": "icon-shield1",
"selected": False,
"target": "_admin",
}
)

return items

def each_context(self, request: "HttpRequest") -> "dict[str, Any]":
ret = super().each_context(request)
selected_tenant = get_selected_tenant()
Expand All @@ -192,6 +285,7 @@ def each_context(self, request: "HttpRequest") -> "dict[str, Any]":
ret["active_tenant"] = selected_tenant
ret["active_program"] = selected_program
ret["namespace"] = self.namespace
ret["menu_items"] = self.get_menu_items(request)
return ret # type: ignore

def autocomplete_view(self, request: "HttpRequest") -> HttpResponse:
Expand Down
Loading
Loading