djangoplay-web / Apps / DjangoPlay — teamcentral App
DocsDjangoPlay WebAppsDjangoPlay — teamcentral App

DjangoPlay — teamcentral App

teamcentral (verbose name "Teams", AppConfig.name = "teamcentral") is DjangoPlay's HR / organizational structure app. Where users deliberately owns identity only (login credentials, SSO linkage, so...

15 min readApplies to v1.2.2
On this page ▾
  1. 1. Summary
  2. 2. Architecture
  3. 2.1 The two profile types and what links to what
  4. 2.2 Request-flow — same thin-view/service-layer convention as the rest of the platform
  5. 2.3 Soft delete — and a real gap in how it connects to audit
  6. 3. Data Models
  7. 3.1 Master/status data (near-identical code+name lookup tables)
  8. 3.2 Address
  9. 3.3 EmploymentProfile (the largest model — ~30 fields)
  10. 3.4 MemberProfile
  11. 3.5 Team
  12. 3.6 LeaveApplication and LeaveBalance
  13. 4. Exceptions
  14. 5. A second real bug — MemberLifecycleService.update_member()
  15. 6. Services (teamcentral/services/)
  16. 7. API Surface
  17. 7.1 Inherited request/permission behavior (from utilities.api.generic_viewsets.BaseViewSet, shared across the platform, not teamcentral-specific)
  18. 8. Admin UI
  19. 9. Known Gaps / Bugs / Things Worth Confirming With the Team
  20. 10. Integration Points (cross-app)
  21. 11. Quick Reference — Required Seed Data

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

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

plaintext
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:

plaintext
views/api/v1/
├── crud/<model>.py        # DRF ModelViewSet subclass (BaseViewSet), registered on a DefaultRouter
├── read/detail/<model>.py # dedicated read-only detail endpoint
├── read/list/<model>.py   # dedicated read-only list endpoint
├── read/history/<model>.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 <model>.deleted / <model>.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 <model>.deleted or <model>.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 ModelViewSets (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 EmploymentProfiles — 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