--- since: 1.2.1 --- # DjangoPlay — `teamcentral` App > Doc generated by direct inspection of every model, service, exception, apps.py, urls.py, one representative view/serializer/admin file per model family (all models follow an identical CRUD/read/history/list scaffold), plus cross-app checks against `users`, `policyengine`, `mailer`, `audit`, `invoices`, `fincore`, and `utilities.api.generic_viewsets`. ## 1. Summary `teamcentral` (verbose name **"Teams"**, `AppConfig.name = "teamcentral"`) is DjangoPlay's **HR / organizational structure** app. Where `users` deliberately owns *identity only* (login credentials, SSO linkage, soft-delete flags — see the `users` doc), `teamcentral` owns everything **about** a person once they're identified: employment details, org-chart position, addresses, leave management, and (for social/self-signup users) a separate lightweight "member" profile. This is a real production split, not a design aspiration — migration `0002_migrate_employee_to_employmentprofile.py` moved **1,255 rows** out of a legacy `users_employee` table into the new `teamcentral_employmentprofile` table, and fixed up a self-referencing `manager_id` FK and a `teamcentral_memberprofile.employee_id → user_identity_id` rename in the same atomic migration. The two profile types are enforced as **mutually exclusive by convention** (docstrings state a `UserIdentity` can have an `EmploymentProfile` *or* a `MemberProfile`, never both, keyed off the same email), though — see §9 — this exclusivity is stated in comments, not enforced by a database constraint or a service-layer check anywhere in this app. 12 models, all registered wholesale into `audit.lifecycle.registry.AUDIT_TRACKED_MODELS` in `TeamcentralConfig.ready()` — every model in this app gets automatic create/update diff tracking (see the `audit` doc, §2.3) purely by being listed there. --- ## 2. Architecture ### 2.1 The two profile types and what links to what ``` users.UserIdentity (identity only — login, SSO, is_active/is_verified) │ ├── OneToOne ──▶ teamcentral.EmploymentProfile (HR record — created by admin/superuser, │ "never for social/member signups") │ │ │ ├── FK → Department (PROTECT) │ ├── FK → Role (PROTECT) [has `rank` — lower = higher rank] │ ├── FK → Team (SET_NULL) │ ├── FK → self "manager" (SET_NULL) — org-chart parent │ ├── FK → EmploymentStatus (PROTECT) │ ├── FK → EmployeeType (PROTECT) │ └── FK → Address (PROTECT, nullable) │ └── OneToOne ──▶ teamcentral.MemberProfile (social/contact record — created on Google/Apple/Email self-signup) │ ├── FK → MemberStatus (PROTECT) └── FK → Address (PROTECT, nullable) teamcentral.LeaveApplication ──FK──▶ UserIdentity, LeaveType, approver (→ UserIdentity) teamcentral.LeaveBalance ──FK──▶ UserIdentity, LeaveType (unique per user+type+year) teamcentral.Address ──FK──▶ UserIdentity (owner) (exactly one active per owner, DB-level UniqueConstraint) ``` `UserIdentity.employment_profile` / `.member_profile` (documented in the `users` app) are just lazy property shortcuts into these tables — `users` never queries or owns this data itself. ### 2.2 Request-flow — same thin-view/service-layer convention as the rest of the platform Every model gets an identical, generated-feeling scaffold: ``` views/api/v1/ ├── crud/.py # DRF ModelViewSet subclass (BaseViewSet), registered on a DefaultRouter ├── read/detail/.py # dedicated read-only detail endpoint ├── read/list/.py # dedicated read-only list endpoint ├── read/history/.py# django-simple-history-backed change history endpoint └── ui/autocomplete.py # django-autocomplete-light (dal) Select2 endpoints, one class per model ``` mounted under `teamcentral/urls.py` → `""` includes `teamcentral.views.api.v1` → `crud/`, `read/`, `ui/`, `ops/` sub-routers (plus a plain `health/` check). This is the same four-way split (`crud` / `read` / `ui` / `ops`) used elsewhere in the platform. Business logic that's more than a straight field-mapping lives in `teamcentral/services/` (7 services, see §6) — not in the views, not in the serializers. ### 2.3 Soft delete — and a real gap in how it connects to `audit` Every model in this app inherits `TimeStampedModel` (from `core.models`), which provides a *generic* `soft_delete()` / `restore()` implementation that: 1. Sets `deleted_at` / `is_active`, 2. Saves only the changed fields, and 3. **Fires `audit.signals.events.post_soft_delete` / `post_restore`** — the signal `audit`'s own `audit/signals/lifecycle.py` listens for to emit `.deleted` / `.restored` audit events (see the `audit` doc, §2.2). **Every single model in `teamcentral` overrides `soft_delete()`/`restore()` with its own version** (to set model-specific side effects — e.g. `Address` deactivates rather than nulling city/state, `EmploymentProfile` also sets `employment_status` to `TERM` and stamps a `termination_date`, `Team.soft_delete()` cascades `self.employment_profiles.update(team=None)`). **None of these overrides call `super().soft_delete()` or manually fire `post_soft_delete`/`post_restore`.** They all reimplement the field-setting logic and call `.save()` directly instead. **Practical consequence:** despite every model in this app being registered in `AUDIT_TRACKED_MODELS`, **no `.deleted` or `.restored` audit event is ever emitted for any teamcentral model**, because the code path that would fire it is never reached. Ordinary `.created`/`.updated` events *are* still captured correctly (those come from the separate `pre_save`/`post_save` global receivers in `audit.lifecycle.tracking`, which this app doesn't touch). This is worth flagging to whoever owns HR/compliance reporting — soft-deleting an employee, department, or leave application currently leaves no audit trail entry for the deletion itself, only for whatever field changes happened up to that point. --- ## 3. Data Models All 12 models inherit `TimeStampedModel` (created_at/updated_at/deleted_at/is_active + soft-delete machinery) and `AuditFieldsModel` (created_by/updated_by/deleted_by FKs to `UserIdentity`) from `core.models`, and all carry `simple_history.HistoricalRecords()` for field-level version history (surfaced via the `read/history/` endpoints). ### 3.1 Master/status data (near-identical `code`+`name` lookup tables) | Model | Key fields | Notes | |---|---|---| | `Department` | `code` (≤12 chars, unique), `name` | | | `Role` | `code` (≤4 chars, unique), `title`, `rank` (PositiveInteger, lower = higher rank) | `rank` drives `EmploymentProfile.role_rank` | | `EmployeeType` | `code` (≤4 chars, unique), `name` | e.g. `FT` | | `EmploymentStatus` | `code` (≤4 chars, unique), `name` | e.g. `ACTV`, `TERM`, `PEND` referenced by code throughout the app | | `MemberStatus` | `code` (≤4 chars, unique), `name` | e.g. `ACTV`, `SUSP`, `PEND` | | `LeaveType` | `code` (≤4 chars, unique), `name`, `default_balance` (decimal, hours) | | All six: `ActiveManager` as `objects` (excludes soft-deleted), plain `Manager` as `all_objects`; `clean()` requires code+name; `save(*, user=None)` stamps `created_by`/`updated_by`. ### 3.2 `Address` - Exactly **one active address per owner**, enforced with a real DB constraint: `UniqueConstraint(fields=["owner"], condition=Q(is_active=True), name="one_active_address_per_owner")` — not just a service-layer convention. - `AddressType` choices: `CURRENT` / `PERMANENT`. `Address.get_preferred_address(owner)` classmethod prefers `CURRENT`, falls back to `PERMANENT`, else `None`. - Owner FK is `on_delete=SET_NULL` — an address can outlive its owning identity. - A `TODO` comment block at the top of the file documents a planned redesign: cap at 2 addresses (1 current + 1 permanent) and switch `MemberProfile`'s relation to a M2M — not yet implemented. ### 3.3 `EmploymentProfile` (the largest model — ~30 fields) Owns organization placement (department/role/team/manager), employment dates (hire/termination/probation/contract), compensation (`job_title`, `salary`, `approval_limit`), personal HR data (DOB, gender, marital status, national ID), emergency contact, `bank_details` (JSONField), `notes`, and a linked `Address`. - **`employee_code`**: auto-generated on first save as `DJP` + 12 uppercase hex chars (`EmploymentProfileManager._generate_employee_code`), retried up to 10 times on collision, `editable=False`, validated by regex `^DJP[A-F0-9]{12}$`. - **New-record auto-defaults** (in `save()`, only applied when `self.pk is None`): if not explicitly provided, `employment_status` defaults to whatever row has `code="ACTV"`, `employee_type` to `code="FT"`, `department` to `code="DEFAULT"`, `role` to `code="SSO"` — each looked up with `.filter(...).first()` (silently `None` if the seed row is missing, no error raised). - **⚠️ Notable oddity**: if `hire_date` isn't supplied, `save()` invents one — a random date within the last 10 years (`random.randint` over a `timedelta`). This reads like leftover demo/seed-data generation logic rather than something intended for real HR data entry; worth confirming with the team whether this is intentional or should require an explicit `hire_date` on create. - `soft_delete()`: sets `employment_status` to whatever row has `code="TERM"` (a **hard `.get()`** — will raise `DoesNotExist` if that seed row doesn't exist, unlike the softer `.filter().first()` used for the create-time defaults) and stamps `termination_date = today`. - **Properties**: `is_active_employee` (status is `ACTV` AND `hire_date` has passed AND not yet terminated), `role_rank` (falls back to total `Role` count if no role set — meaning an employee with no role ranks *last*, not highest), `can_approve_invoice(invoice_amount)` (`Decimal(amount) <= approval_limit`). **This last method is a defined-but-unused integration point**: a repo-wide search for `can_approve_invoice` and `approval_limit` found no references in `invoices` or `fincore`. The approval-limit field exists and the comparison method exists, but nothing in the invoice/finance approval flow currently calls it — worth confirming with whoever owns `invoices`/`fincore` whether this wiring was dropped or is planned but not yet built. ### 3.4 `MemberProfile` Lighter-weight sibling of `EmploymentProfile` for self-signup users: `email` (unique), `first_name`/`last_name`, `phone_number`, `address` FK, `status` FK (`MemberStatus`), `preferences` (JSONField), `avatar`. - **`member_code`**: same pattern as `employee_code` — `MBR` + 12 uppercase hex chars, retried up to 10 times, generated inside `save()` itself (not a manager method like `EmploymentProfile`'s). - No `clean()` override — despite the pattern every other model in this app follows (raise `TeamCentralValidationError` if required fields are missing), `MemberProfile.save()` calls `self.clean()` but that resolves to Django's default no-op `Model.clean()`. Field-level validation (email format, phone number) is instead enforced one layer up, in `MemberLifecycleService.validate_member_payload()` — so validation still happens for service-created members, just not for anything that constructs a `MemberProfile` directly and calls `.save()`. ### 3.5 `Team` `name` + `department` FK (`UniqueConstraint` on `name`+`department` pair — team names are only unique *within* a department) + `leader` FK (→ `UserIdentity`, `SET_NULL`) + `description`. `clean()` enforces the leader must belong to the same department as the team (only checked if the leader has an `employmentprofile`). `soft_delete()` cascades: `self.employment_profiles.update(team=None)` before deactivating — orphaned employees are unassigned rather than left pointing at a dead team. ### 3.6 `LeaveApplication` and `LeaveBalance` - `LeaveApplication`: `user_identity`, `leave_type`, `start_date`/`end_date` (nullable, for partial-day requests), `hours` (nullable decimal, for partial-day), `status` (`PENDING`/`APPROVED`/`REJECTED`/`CANCELLED`), `approver` FK, `reason`. `clean()` validates date ordering, positive hours, and that any `approver` set is an active employee. - A `TODO` block at the top of the file documents a substantial list of **not-yet-built** functionality: partial-hours limits (max 12h, no mixing with date ranges), default-approver-is-manager fallback logic, approval/rejection email notifications, overlapping-leave prevention, and leave-type rule enforcement. **None of this exists yet** — `LeaveApplicationService.create_application()` is currently a single method that does nothing but construct-and-save; there is no approve/reject/cancel service method, no balance deduction on approval, and no notification hook anywhere in this app. - `LeaveBalance`: `user_identity` + `leave_type` + `year` unique together (both a DB `UniqueConstraint` and an app-level `EmployeeLifecycleService.is_duplicate_balance()` check), `balance`/`used` decimals, `reset_date`. `clean()` rejects `used > balance` and years more than one year in the future. - `EmployeeLifecycleService.has_sufficient_balance()` computes `requested = (end_date - start_date).days + 1` and compares against `balance - used` — note this counts **calendar days**, not the `hours`-based partial-leave model the `LeaveApplication` schema otherwise supports; the two leave-accounting units (days vs. hours) aren't reconciled anywhere in this app. --- ## 4. Exceptions Single `TeamCentralValidationError(ValidationError)` class (`teamcentral/exceptions/exceptions.py`) shared by every model/service in the app, with a **closed vocabulary of valid `code` values** (~35 entries covering address/department/employee-type/leave/member/team/role errors) — passing an unrecognized `code` raises a `ValueError` immediately rather than silently accepting it. Supports string, dict (field→message), or list message shapes, with a `to_dict()` for consistent API error responses. **⚠️ Bug**: `AddressManagementService.create_address()` raises `AddressValidationError(..., code="missing_owner")` when no owner is supplied — but `"missing_owner"` is **not** in the exception class's valid-codes list (the list has `"missing_user"` instead). This means the intended validation error never actually surfaces as a clean 400 — the `ValueError("Invalid error code: missing_owner")` raised inside `TeamCentralValidationError.__init__` propagates instead, which `BaseViewSet.create()`'s generic `except Exception` handler will catch and turn into an opaque 500 rather than the descriptive 400 the code was clearly trying to produce. Two thin app-specific subclasses live in `users.exceptions` rather than in this app itself and are imported back into `teamcentral` services: `LeaveTeamCentralValidationError` (used by `LeaveApplication.soft_delete`/`restore`) and `MemberValidationError`/`TeamValidationError`/`AddressValidationError`/`UserIdentityValidationError` (used by the various `teamcentral` services) — worth noting for anyone searching for "where is `AddressValidationError` defined" and finding it isn't in `teamcentral` at all. --- ## 5. A second real bug — `MemberLifecycleService.update_member()` ```python member.phone_number = data.get("phone_number"), # ← trailing comma ``` The trailing comma turns the right-hand side into a **1-tuple**, e.g. `("+15551234567",)`, not the string itself. Since `phone_number` is a plain `CharField`, this will be coerced to its string representation on save (something like `"('+15551234567',)"`) rather than the actual phone number — every member profile updated through `MemberLifecycleService.update_member()` will get a corrupted `phone_number` value. (`MemberLifecycleService.create_member()` does not have this bug — it passes `phone_number=data.get("phone_number")` correctly without a trailing comma.) --- ## 6. Services (`teamcentral/services/`) | Service | Owns | |---|---| | `EmployeeLifecycleService` | Employee payload validation/normalization (email, phone via `utilities.utils.locations.phone_number_validations`), `create_employee()` (creates `UserIdentity` **and** `EmploymentProfile` together, atomically), leave-balance duplicate/sufficiency checks, and a set of `get_*_values()` helpers that return plain dicts of active Department/Role/Team/EmployeeType/EmploymentStatus rows — clearly meant to back dropdown/select UI without exposing full model serializers. | | `MemberLifecycleService` | `MemberProfile` create/update, plus `activate_from_signup(signup_request)` — the bridge called when a `users.SignupRequest` completes: marks the `MemberProfile` `ACTV`, the `UserIdentity.is_verified = True`, and — if an `EmploymentProfile` also happens to exist for that identity — marks it `ACTV` too (wrapped in a `try/except RelatedObjectDoesNotExist` guard so pure members don't error). | | `LeaveApplicationService` | Currently just `create_application()` — see §3.6 for what's missing. | | `LeavePolicyService` | `allocate_leave_balance()` — idempotent (returns the existing balance rather than erroring if one already exists for that user/type/year). | | `OnboardingPolicy` | Centralizes the *other* set of default master-data codes used during SSO/self-signup onboarding: `department=Department(code="SSO")`, `role=Role(code="SSO")`, `employment_status=EmploymentStatus(code="PEND")`, `employee_type=EmployeeType(code="SSO")`, `member_status=MemberStatus(code="PEND")` — all via `.get()`, so a missing seed row raises a hard `ValidationError` ("onboarding configuration is incomplete"), unlike `EmploymentProfile.save()`'s own softer fallback defaults. Its docstring explicitly states **"Identity layer MUST NOT import this"** — i.e. `users` is expected to reach `teamcentral` business rules only through a service call, never by importing `OnboardingPolicy` directly into identity code. | | `TeamManagementService` | `create_team()` — name normalization + creation. | | `AddressManagementService` | `create_address()`/`update_address()` — enforces "exactly one active address per owner" at the service layer too (bulk-deactivates existing active addresses before creating a new one), on top of the DB constraint. Contains the `code="missing_owner"` bug noted in §4. | **Two independent sets of "default" master-data codes exist in this app** and are used by different creation paths: `EmploymentProfile.save()`'s own inline fallback (`ACTV`/`FT`/`DEFAULT`/`SSO`) for direct/admin-created profiles, and `OnboardingPolicy` (`SSO`/`SSO`/`PEND`/`SSO`/`PEND`) for signup-flow-created profiles. Both sets of seed rows (`Department`, `Role`, `EmploymentStatus`, `EmployeeType`, `MemberStatus` rows with codes `DEFAULT`, `SSO`, `ACTV`, `FT`, `PEND`, `TERM`, `SUSP`) need to exist in the database for this app to function correctly across both paths — this is exactly the kind of implicit seed-data dependency worth calling out for anyone standing up a fresh environment. --- ## 7. API Surface Routed under `teamcentral/urls.py` → `teamcentral.views.api.v1`: - **`crud/`** — a single `DefaultRouter` registering all 12 models as full `ModelViewSet`s (`BaseViewSet` from `utilities.api.generic_viewsets`, shared platform-wide): `addresses`, `departments`, `employee-types`, `employment-statuses`, `leave-applications`, `leave-balances`, `leave-types`, `member-profiles`, `member-statuses`, `roles`, `teams`, `employment-profiles`. - **`read/`** — parallel `detail/`, `list/`, `history/` endpoints per model (the `history/` ones surface `simple_history` records). - **`ui/`** — `django-autocomplete-light` (`dal`) Select2 endpoints for Address, Department, Team, Role, MemberProfile, LeaveType, and a bespoke `EmploymentProfileAutocomplete` (see §9 for a labeling bug in this one). - **`ops/`** — `DepartmentBulkUpdateAPIView` / `RoleBulkUpdateAPIView` / `TeamBulkUpdateAPIView` (each restricted to bulk-editing only a `name` field, via `utilities.api.bulk_views.BaseBulkUpdateAPIView`), and `EmployeeExportAPIView` (CSV export of all active `EmploymentProfile`s — see §9 for a data-quality bug in the export's name resolution). - **`health/`** — standard `HealthCheckView`. ### 7.1 Inherited request/permission behavior (from `utilities.api.generic_viewsets.BaseViewSet`, shared across the platform, not `teamcentral`-specific) - Auth: JWT (`rest_framework_simplejwt`), `IsAuthenticated` base requirement. - **Permissions are Django's native per-model permission system**, dynamically mapped: `list`/`retrieve` → `view`, `create` → `add`, `update`/`partial_update` → `change`, `destroy` → `delete`, checked as `request.user.has_perm(f"{app_label}.{action}_{model_name}")` (e.g. `teamcentral.view_employmentprofile`). There is **no field-level permission distinction** — a user with `teamcentral.view_employmentprofile` sees every field the read serializer exposes, including `salary` and `bank_details` (see §9). - List/detail responses are cached (`cache_timeout = 172800` = 48h) keyed by user + query params, invalidated on create/update/soft-delete via `cache.delete_pattern`. - Soft delete (`DELETE`) calls `instance.soft_delete(user=request.user)` — routing straight into the per-model overrides discussed in §2.3, which is why the audit-signal gap applies to every delete made through this API, not just admin-initiated ones. --- ## 8. Admin UI 12 admin classes, all following the shared `BaseAdminPage` + `AdminIconDecorator.register_with_icon` + declarative `form_layout` (tabbed groups) + AJAX `filter_config` convention used platform-wide (see the `audit` doc §8 for the same base class). `EmploymentProfileAdmin` is the most elaborate: 6 tabs (Basic/Organization/Employment/Personal/Additional/System), `select_related_fields` for query efficiency on a wide model, and CSV-friendly `search_fields`. **Note**: `EmploymentProfileAdmin.has_view_permission()` is hardcoded to `return True` — it unconditionally grants view access regardless of the requesting admin user's actual Django permissions, overriding the framework default. Combined with the lack of field-level restriction noted in §7.1, this means **salary and bank_details are visible to anyone who can reach the Django admin at all**, not just users explicitly granted `teamcentral.view_employmentprofile`. Worth confirming with the security/compliance owner whether this is intentional (e.g. admin access is already gated tightly enough upstream) or an oversight. --- ## 9. Known Gaps / Bugs / Things Worth Confirming With the Team 1. **`LeaveApplication`/`LeaveApplicationService` are missing essentially all approval-workflow logic** the model's own `TODO` comments describe as intended: no approve/reject/cancel service methods, no balance deduction on approval, no notifications, no overlap prevention. Only "create a pending application" currently works end-to-end. (§3.6) 2. **`Address` model docstring/TODO documents a planned redesign** (max 2 addresses per owner via M2M) that hasn't been implemented — current model is FK-based, one active address per owner only. (§3.2) --- ## 10. Integration Points (cross-app) | App | How it touches `teamcentral` | |---|---| | **`users`** | Owns the `UserIdentity` every `EmploymentProfile`/`MemberProfile`/`Address`/`LeaveApplication`/`LeaveBalance`/`Team.leader` FK points at. `users.exceptions` hosts several of `teamcentral`'s service-layer exception classes (`AddressValidationError`, `MemberValidationError`, `TeamValidationError`, `LeaveTeamCentralValidationError`). The `users` signup flow calls into `MemberLifecycleService.activate_from_signup()` and (indirectly, per its own docstring) is expected to route onboarding defaults through `teamcentral.services.OnboardingPolicy` rather than importing `teamcentral` models directly. | | **`audit`** | All 12 models registered in `AUDIT_TRACKED_MODELS` in `TeamcentralConfig.ready()` — gets automatic create/update diff tracking for free, but see the delete/restore signal gap in §2.3/§9. | | **`mailer`** | `mailer/flows/member/signup.py` and `verification.py` import `teamcentral.models.MemberProfile` directly for signup/verification email flows. | | **`policyengine`** | `policyengine/configs/roles.py` references the `teamcentral` app label for role-based access config; `policyengine/configs/overrides.py` lists specific `teamcentral.*` model permission overrides (`address`, `department`, `employeetype`, `employmentstatus`, `leaveapplication`, `leavebalance`, `memberstatus`, `team`). | | **`invoices` / `fincore`** | **No current code reference** despite `EmploymentProfile.approval_limit`/`can_approve_invoice()` existing specifically to support an invoice-approval-authority check — see gap #5. | | **`utilities`** | `BaseViewSet`, `BaseBulkUpdateAPIView`, `SoftDeleteMixin`, `CacheMixin`, admin base classes (`BaseAdminPage`, `AdminIconDecorator`, `changelist_filter`) — all shared platform infrastructure `teamcentral` builds its API/admin surface on top of, owned elsewhere. | | **`core`** | `TimeStampedModel`, `ActiveManager`, `AuditFieldsModel` — the abstract base classes every model in this app inherits. | --- ## 11. Quick Reference — Required Seed Data Based on codes referenced by name throughout this app's `save()`/`soft_delete()`/`OnboardingPolicy` logic, a fresh environment needs at minimum these master-data rows to exist before employee/member creation will work without hitting fallback-`None` or hard `DoesNotExist` errors: - `Department`: `DEFAULT`, `SSO` - `Role`: `SSO` - `EmploymentStatus`: `ACTV`, `TERM`, `PEND` - `EmployeeType`: `FT`, `SSO` - `MemberStatus`: `ACTV`, `SUSP`, `PEND`