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...
On this page ▾
- 1. Summary
- 2. Architecture
- 2.1 The two profile types and what links to what
- 2.2 Request-flow — same thin-view/service-layer convention as the rest of the platform
- 2.3 Soft delete — and a real gap in how it connects to audit
- 3. Data Models
- 3.1 Master/status data (near-identical code+name lookup tables)
- 3.2 Address
- 3.3 EmploymentProfile (the largest model — ~30 fields)
- 3.4 MemberProfile
- 3.5 Team
- 3.6 LeaveApplication and LeaveBalance
- 4. Exceptions
- 5. A second real bug — MemberLifecycleService.update_member()
- 6. Services (teamcentral/services/)
- 7. API Surface
- 7.1 Inherited request/permission behavior (from utilities.api.generic_viewsets.BaseViewSet, shared across the platform, not teamcentral-specific)
- 8. Admin UI
- 9. Known Gaps / Bugs / Things Worth Confirming With the Team
- 10. Integration Points (cross-app)
- 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, andutilities.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
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/<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 modelmounted 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:
- Sets
deleted_at/is_active, - Saves only the changed fields, and
- Fires
audit.signals.events.post_soft_delete/post_restore— the signalaudit's ownaudit/signals/lifecycle.pylistens for to emit<model>.deleted/<model>.restoredaudit events (see theauditdoc, §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. AddressTypechoices:CURRENT/PERMANENT.Address.get_preferred_address(owner)classmethod prefersCURRENT, falls back toPERMANENT, elseNone.- Owner FK is
on_delete=SET_NULL— an address can outlive its owning identity. - A
TODOcomment block at the top of the file documents a planned redesign: cap at 2 addresses (1 current + 1 permanent) and switchMemberProfile'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 asDJP+ 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 whenself.pk is None): if not explicitly provided,employment_statusdefaults to whatever row hascode="ACTV",employee_typetocode="FT",departmenttocode="DEFAULT",roletocode="SSO"— each looked up with.filter(...).first()(silentlyNoneif the seed row is missing, no error raised). -
⚠️ Notable oddity: if
hire_dateisn't supplied,save()invents one — a random date within the last 10 years (random.randintover atimedelta). 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 explicithire_dateon create. -
soft_delete(): setsemployment_statusto whatever row hascode="TERM"(a hard.get()— will raiseDoesNotExistif that seed row doesn't exist, unlike the softer.filter().first()used for the create-time defaults) and stampstermination_date = today. -
Properties:
is_active_employee(status isACTVANDhire_datehas passed AND not yet terminated),role_rank(falls back to totalRolecount 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_invoiceandapproval_limitfound no references ininvoicesorfincore. 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 ownsinvoices/fincorewhether 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 asemployee_code—MBR+ 12 uppercase hex chars, retried up to 10 times, generated insidesave()itself (not a manager method likeEmploymentProfile's).- No
clean()override — despite the pattern every other model in this app follows (raiseTeamCentralValidationErrorif required fields are missing),MemberProfile.save()callsself.clean()but that resolves to Django's default no-opModel.clean(). Field-level validation (email format, phone number) is instead enforced one layer up, inMemberLifecycleService.validate_member_payload()— so validation still happens for service-created members, just not for anything that constructs aMemberProfiledirectly 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),approverFK,reason.clean()validates date ordering, positive hours, and that anyapproverset is an active employee.- A
TODOblock 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+yearunique together (both a DBUniqueConstraintand an app-levelEmployeeLifecycleService.is_duplicate_balance()check),balance/useddecimals,reset_date.clean()rejectsused > balanceand years more than one year in the future.EmployeeLifecycleService.has_sufficient_balance()computesrequested = (end_date - start_date).days + 1and compares againstbalance - used— note this counts calendar days, not thehours-based partial-leave model theLeaveApplicationschema 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()
member.phone_number = data.get("phone_number"), # ← trailing commaThe 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 singleDefaultRouterregistering all 12 models as fullModelViewSets (BaseViewSetfromutilities.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/— paralleldetail/,list/,history/endpoints per model (thehistory/ones surfacesimple_historyrecords).ui/—django-autocomplete-light(dal) Select2 endpoints for Address, Department, Team, Role, MemberProfile, LeaveType, and a bespokeEmploymentProfileAutocomplete(see §9 for a labeling bug in this one).ops/—DepartmentBulkUpdateAPIView/RoleBulkUpdateAPIView/TeamBulkUpdateAPIView(each restricted to bulk-editing only anamefield, viautilities.api.bulk_views.BaseBulkUpdateAPIView), andEmployeeExportAPIView(CSV export of all activeEmploymentProfiles — see §9 for a data-quality bug in the export's name resolution).health/— standardHealthCheckView.
7.1 Inherited request/permission behavior (from utilities.api.generic_viewsets.BaseViewSet, shared across the platform, not teamcentral-specific)
- Auth: JWT (
rest_framework_simplejwt),IsAuthenticatedbase requirement. - Permissions are Django's native per-model permission system, dynamically mapped:
list/retrieve→view,create→add,update/partial_update→change,destroy→delete, checked asrequest.user.has_perm(f"{app_label}.{action}_{model_name}")(e.g.teamcentral.view_employmentprofile). There is no field-level permission distinction — a user withteamcentral.view_employmentprofilesees every field the read serializer exposes, includingsalaryandbank_details(see §9). - List/detail responses are cached (
cache_timeout = 172800= 48h) keyed by user + query params, invalidated on create/update/soft-delete viacache.delete_pattern. - Soft delete (
DELETE) callsinstance.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
LeaveApplication/LeaveApplicationServiceare missing essentially all approval-workflow logic the model's ownTODOcomments 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)Addressmodel 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,SSORole:SSOEmploymentStatus:ACTV,TERM,PENDEmployeeType:FT,SSOMemberStatus:ACTV,SUSP,PEND