diff --git a/README.md b/README.md index 5057bf0..bd029dd 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,69 @@ -# MOHPortal - -MOH is a : +# MOHPortal -- Dev agency -- Managed service provider -- General consulting/contracting staffing management for all of TSYS Group (especially HFNOC). +MOHPortal (MerchantsOfHope.org) — a modular, secure recruiting and staffing platform built to serve TSYS Group and its lines of business. The platform supports multi-tenant operations, agency-style recruiting workflows, managed services, and deep integration with existing TSYS business units. +## Purpose and Goals +- Provide a central, extensible platform for recruiting, contracting, and managed services across TSYS Group. +- Support multiple independent tenants with strict data isolation. +- Ship as a containerized application suitable for Docker Compose and Kubernetes deployments. +- Meet enterprise security, privacy, and accessibility requirements for government and commercial contracts. -Also the platform will be made generally available in an effort to onshore recruiting as RWSCP recapitalizes the American dream. +## Key Capabilities +- Job seeker experience: browse jobs, upload/resume parsing, apply, track application status. +- Employer experience: create/manage job postings, review candidates, manage hiring workflows. +- Candidate lifecycle management: screening stages, interview scheduling, offer management. +- Tenant administration: tenant onboarding, role-based access control, tenant-scoped configuration. +- Integrations: identity providers (OIDC, social login), ATS/HR systems, internal TSYS services, analytics. -Will integrate with each TSYS BU Dolinar instance. \ No newline at end of file +## Architecture & Integration Notes +- Modular microservice-friendly design; services should be containerized and communicate over internal networks. +- Use the current directory name to determine the primary language/runtime for implementation and test artifacts. +- Only expose the main web interface externally; all other service ports remain on internal docker/k8s networks. +- Provide API-first design with versioned REST/GraphQL endpoints and clear schema contracts for downstream integrations. + +## Multi-Tenancy & Data Isolation +- Strong tenant separation (logical and storage-level isolation where appropriate). +- Tenant configuration, branding, and feature flags per tenant. +- Admins scoped by tenant; global system admins for platform operations only. + +## Authentication & Authorization +- Support OIDC providers and federated social logins (configurable per tenant). +- Role-based access control (RBAC) and least-privilege principles. +- Audit logging for administrative actions and authentication events. + +## Accessibility & Compliance +- Target WCAG 2.1 AA at minimum to satisfy government contract accessibility requirements. +- English-only for MVP; ensure UI and content flows are accessible and keyboard-navigable. +- Compliance posture: design with PCI, GDPR, SOC, FedRAMP considerations in mind. Implement data minimization, encryption at rest/in transit, and strong access controls. +- Assume USA law jurisdiction for legal and privacy decisions. + +## Security & Privacy +- Encrypt sensitive data at rest and in transit (TLS everywhere). +- Rotate secrets and credentials using secrets management (Vault or cloud-native equivalents). +- Implement rate limiting, WAF patterns, hardened container images, and supply-chain security best practices. +- Logging and monitoring with alerting and observability (prometheus/ELK or equivalent). + +## Development Practices +- Follow Test Driven Development (TDD) with comprehensive unit, integration, and E2E tests. +- Maintain a docker-compose.yml for local stacks and a Kubernetes-friendly deployment manifest for production. +- Adopt CI/CD pipelines for automated builds, tests, image scans, and deployments. +- Keep the repo organized by service, tests, and infrastructure-as-code. Do not create artifacts outside the current directory. + +## Deployment & Operations +- Ship as Docker container(s). Use a naming convention for artifacts and containers that maps agent-language-function (e.g., copilot-python-api). +- Only expose the main web UI port externally; other services on internal stack networks. +- Prepare for k8s deployment: manifests, helm charts, resource requests/limits, and readiness/liveness probes. +- Define backup, disaster recovery, and tenant migration procedures. + +## Governance & Contributing +- Document coding standards, security checklists, and QA acceptance criteria. +- Review process for changes that affect compliance or tenant data handling. +- Add clear contribution guidelines and changelog for tenant-impacting changes. + +## Next Steps (MVP) +- Define core user stories (job search, apply, post job, admin tenant onboarding). +- Scaffold services and initial docker-compose stack. +- Implement auth (OIDC), multi-tenant data model, and accessible UI skeleton. +- Establish CI pipeline and baseline security scans. + +For questions or to propose changes to platform scope, contact the PMO and reference the project-specific agent guidelines in the repository. diff --git a/jobs/__init__.py b/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jobs/models.py b/jobs/models.py new file mode 100644 index 0000000..52ea890 --- /dev/null +++ b/jobs/models.py @@ -0,0 +1,90 @@ +from django.db import models +from tenants.models import Tenant +from users.models import User + + +class JobCategory(models.Model): + """ + Job category for organizing job postings. + """ + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class Job(models.Model): + """ + Job posting model for the recruiting platform. + """ + STATUS_CHOICES = [ + ('draft', 'Draft'), + ('published', 'Published'), + ('closed', 'Closed'), + ('archived', 'Archived'), + ] + + title = models.CharField(max_length=200) + description = models.TextField() + requirements = models.TextField() + responsibilities = models.TextField() + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE) + posted_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name='jobs_posted') + + category = models.ForeignKey(JobCategory, on_delete=models.SET_NULL, null=True, blank=True) + location = models.CharField(max_length=200) + employment_type = models.CharField(max_length=50, choices=[ + ('full-time', 'Full-time'), + ('part-time', 'Part-time'), + ('contract', 'Contract'), + ('temporary', 'Temporary'), + ('internship', 'Internship'), + ('remote', 'Remote'), + ]) + + salary_min = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + salary_max = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft') + is_remote = models.BooleanField(default=False) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + published_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + + def __str__(self): + return f"{self.title} - {self.tenant.name}" + + @property + def is_active(self): + return self.status == 'published' and self.expires_at and self.expires_at > self.created_at + + +class Application(models.Model): + """ + Job application model to track candidate applications. + """ + STATUS_CHOICES = [ + ('submitted', 'Submitted'), + ('reviewed', 'Reviewed'), + ('shortlisted', 'Shortlisted'), + ('interviewed', 'Interviewed'), + ('offered', 'Offered'), + ('rejected', 'Rejected'), + ('withdrawn', 'Withdrawn'), + ] + + job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='applications') + applicant = models.ForeignKey(User, on_delete=models.CASCADE, related_name='applications') + cover_letter = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='submitted') + + applied_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.applicant.username} - {self.job.title}" \ No newline at end of file diff --git a/jobs/serializers.py b/jobs/serializers.py new file mode 100644 index 0000000..f059987 --- /dev/null +++ b/jobs/serializers.py @@ -0,0 +1,40 @@ +from rest_framework import serializers +from .models import Job, Application, JobCategory +from users.serializers import UserSerializer +from tenants.serializers import TenantSerializer + + +class JobCategorySerializer(serializers.ModelSerializer): + """ + Serializer for the JobCategory model. + """ + class Meta: + model = JobCategory + fields = '__all__' + + +class JobSerializer(serializers.ModelSerializer): + """ + Serializer for the Job model. + """ + posted_by = UserSerializer(read_only=True) + tenant = TenantSerializer(read_only=True) + category = JobCategorySerializer(read_only=True) + + class Meta: + model = Job + fields = '__all__' + read_only_fields = ('posted_by', 'tenant', 'created_at', 'updated_at', 'published_at') + + +class ApplicationSerializer(serializers.ModelSerializer): + """ + Serializer for the Application model. + """ + job = JobSerializer(read_only=True) + applicant = UserSerializer(read_only=True) + + class Meta: + model = Application + fields = '__all__' + read_only_fields = ('job', 'applicant', 'applied_at', 'updated_at') \ No newline at end of file diff --git a/jobs/views.py b/jobs/views.py new file mode 100644 index 0000000..7c8e37b --- /dev/null +++ b/jobs/views.py @@ -0,0 +1,86 @@ +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.decorators import api_view +from .models import Job, Application, JobCategory +from .serializers import JobSerializer, ApplicationSerializer, JobCategorySerializer + + +class JobListView(generics.ListCreateAPIView): + """ + API view to retrieve list of jobs or create a new job. + """ + queryset = Job.objects.all() + serializer_class = JobSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + queryset = Job.objects.all() + tenant_id = self.request.query_params.get('tenant', None) + if tenant_id is not None: + queryset = queryset.filter(tenant_id=tenant_id) + return queryset + + +class JobDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + API view to retrieve, update or delete a single job. + """ + queryset = Job.objects.all() + serializer_class = JobSerializer + permission_classes = [permissions.IsAuthenticated] + + +class ApplicationListView(generics.ListCreateAPIView): + """ + API view to retrieve list of applications or create a new application. + """ + queryset = Application.objects.all() + serializer_class = ApplicationSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + # Set the applicant to the current user + serializer.save(applicant=self.request.user) + + +class ApplicationDetailView(generics.RetrieveUpdateDestroyAPIView): + """ + API view to retrieve, update or delete a single application. + """ + queryset = Application.objects.all() + serializer_class = ApplicationSerializer + permission_classes = [permissions.IsAuthenticated] + + +class JobCategoryListView(generics.ListCreateAPIView): + """ + API view to retrieve list of job categories or create a new category. + """ + queryset = JobCategory.objects.all() + serializer_class = JobCategorySerializer + permission_classes = [permissions.IsAuthenticated] + + +@api_view(['POST']) +def apply_to_job(request, job_id): + """ + API endpoint to apply to a specific job. + """ + try: + job = Job.objects.get(pk=job_id) + except Job.DoesNotExist: + return Response({'error': 'Job not found'}, status=status.HTTP_404_NOT_FOUND) + + # Check if user has already applied + if Application.objects.filter(job=job, applicant=request.user).exists(): + return Response({'error': 'Already applied to this job'}, status=status.HTTP_400_BAD_REQUEST) + + application = Application.objects.create( + job=job, + applicant=request.user, + cover_letter=request.data.get('cover_letter', ''), + status='submitted' + ) + + serializer = ApplicationSerializer(application) + return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..647af26 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mohportal.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/mohportal/settings.py b/mohportal/settings.py new file mode 100644 index 0000000..225507b --- /dev/null +++ b/mohportal/settings.py @@ -0,0 +1,187 @@ +""" +Django settings for MOHPortal project. + +Generated by 'django-admin startproject' on 2025-10-25. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +import os +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'django-insecure-development-key-change-in-production') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.environ.get('DEBUG', 'True').lower() == 'true' + +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'corsheaders', + 'rest_framework', + 'social_django', + # Custom apps + 'users', + 'jobs', + 'tenants', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'social_django.middleware.SocialAuthExceptionMiddleware', +] + +ROOT_URLCONF = 'mohportal.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'social_django.context_processors.backends', + 'social_django.context_processors.login_redirect', + ], + }, + }, +] + +WSGI_APPLICATION = 'mohportal.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('DB_NAME', 'mohportal_db'), + 'USER': os.environ.get('DB_USER', 'mohportal_user'), + 'PASSWORD': os.environ.get('DB_PASSWORD', 'mohportal_password'), + 'HOST': os.environ.get('DB_HOST', 'db'), + 'PORT': os.environ.get('DB_PORT', '5432'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') +STATICFILES_DIRS = [ + BASE_DIR / 'static', +] +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Authentication backends +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'social_core.backends.google.GoogleOpenId', + 'social_core.backends.google.GoogleOAuth2', + 'social_core.backends.github.GithubOAuth2', + 'social_core.backends.azuread_tenant.AzureADTenantOAuth2', +] + +# Social auth settings +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.environ.get('GOOGLE_OAUTH2_KEY') +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.environ.get('GOOGLE_OAUTH2_SECRET') + +SOCIAL_AUTH_GITHUB_KEY = os.environ.get('GITHUB_KEY') +SOCIAL_AUTH_GITHUB_SECRET = os.environ.get('GITHUB_SECRET') + +SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_KEY = os.environ.get('AZUREAD_KEY') +SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_SECRET = os.environ.get('AZUREAD_SECRET') +SOCIAL_AUTH_AZUREAD_TENANT_OAUTH2_TENANT_ID = os.environ.get('AZUREAD_TENANT_ID') + +# Django REST Framework settings +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 20, +} + +# Security settings for production +if not DEBUG: + SECURE_BROWSER_XSS_FILTER = True + SECURE_CONTENT_TYPE_NOSNIFF = True + SECURE_HSTS_INCLUDE_SUBDOMAINS = True + SECURE_HSTS_SECONDS = 3153600 + SECURE_REDIRECT_EXEMPT = [] + SECURE_SSL_REDIRECT = True + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + X_FRAME_OPTIONS = 'DENY' \ No newline at end of file diff --git a/mohportal/urls.py b/mohportal/urls.py new file mode 100644 index 0000000..3da9c39 --- /dev/null +++ b/mohportal/urls.py @@ -0,0 +1,32 @@ +"""MOHPortal URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path('admin/', admin.site.urls), + path('auth/', include('social_django.urls', namespace='social')), + path('api/users/', include('users.urls')), + path('api/jobs/', include('jobs.urls')), + path('api/tenants/', include('tenants.urls')), + path('api-auth/', include('rest_framework.urls')), +] + +# Serve static files in development +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \ No newline at end of file diff --git a/mohportal/wsgi.py b/mohportal/wsgi.py new file mode 100644 index 0000000..0052e59 --- /dev/null +++ b/mohportal/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for MOHPortal project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mohportal.settings') + +application = get_wsgi_application() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a86435 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +Django>=4.2.0 +djangorestframework>=3.14.0 +django-tenant-schemas>=1.11.0 +python-social-auth>=0.3.6 +django-oauth-toolkit>=2.3.0 +psycopg2-binary>=2.9.0 +gunicorn>=21.0.0 +pytest>=7.0.0 +pytest-django>=4.5.0 +factory-boy>=3.2.0 +selenium>=4.10.0 +whitenoise>=6.4.0 +django-cors-headers>=4.0.0 +cryptography>=41.0.0 \ No newline at end of file diff --git a/tenants/__init__.py b/tenants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tenants/models.py b/tenants/models.py new file mode 100644 index 0000000..ee9d2fb --- /dev/null +++ b/tenants/models.py @@ -0,0 +1,36 @@ +from django.db import models + + +class Tenant(models.Model): + """ + Tenant model for multi-tenancy support. + Each tenant represents a separate business unit or organization. + """ + name = models.CharField(max_length=200, unique=True) + subdomain = models.SlugField(max_length=100, unique=True) + schema_name = models.CharField(max_length=100, unique=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Tenant-specific configurations + company_name = models.CharField(max_length=200, blank=True) + contact_email = models.EmailField() + phone_number = models.CharField(max_length=15, blank=True) + website = models.URLField(blank=True) + + # Branding settings + logo = models.ImageField(upload_to='tenant_logos/', null=True, blank=True) + primary_color = models.CharField(max_length=7, default='#007bff') # Hex color + secondary_color = models.CharField(max_length=7, default='#6c757d') # Hex color + + # Feature flags + enable_social_login = models.BooleanField(default=True) + enable_email_notifications = models.BooleanField(default=True) + + def __str__(self): + return self.name + + class Meta: + verbose_name = 'Tenant' + verbose_name_plural = 'Tenants' \ No newline at end of file diff --git a/tenants/serializers.py b/tenants/serializers.py new file mode 100644 index 0000000..9441ed3 --- /dev/null +++ b/tenants/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers +from .models import Tenant + + +class TenantSerializer(serializers.ModelSerializer): + """ + Serializer for the Tenant model. + """ + class Meta: + model = Tenant + fields = '__all__' + read_only_fields = ('created_at', 'updated_at', 'schema_name') \ No newline at end of file diff --git a/users/__init__.py b/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/users/models.py new file mode 100644 index 0000000..a5be6fd --- /dev/null +++ b/users/models.py @@ -0,0 +1,43 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from tenants.models import Tenant + + +class User(AbstractUser): + """ + Custom User model that extends Django's AbstractUser. + Supports multi-tenancy by linking users to specific tenants. + """ + tenant = models.ForeignKey( + Tenant, + on_delete=models.CASCADE, + related_name='users', + null=True, + blank=True + ) + is_job_seeker = models.BooleanField(default=False) + is_employer = models.BooleanField(default=False) + is_tenant_admin = models.BooleanField(default=False) + is_global_admin = models.BooleanField(default=False) + + # Additional fields for job seekers + resume = models.FileField(upload_to='resumes/', null=True, blank=True) + bio = models.TextField(max_length=1000, blank=True) + phone_number = models.CharField(max_length=15, blank=True) + + # Additional fields for employers + company_name = models.CharField(max_length=200, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.username} ({self.email})" + + def get_full_name(self): + """ + Return the user's full name. + """ + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + return self.username \ No newline at end of file diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..686ed7c --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers +from django.contrib.auth.models import User +from .models import User as CustomUser + + +class UserSerializer(serializers.ModelSerializer): + """ + Serializer for the User model. + """ + class Meta: + model = CustomUser + fields = ('id', 'username', 'email', 'first_name', 'last_name', 'is_job_seeker', + 'is_employer', 'is_tenant_admin', 'is_global_admin', 'bio', 'phone_number', + 'company_name', 'created_at', 'updated_at') + read_only_fields = ('id', 'created_at', 'updated_at') \ No newline at end of file diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..5dd6631 --- /dev/null +++ b/users/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('', views.UserListView.as_view(), name='user-list'), + path('/', views.UserDetailView.as_view(), name='user-detail'), + path('current/', views.current_user, name='current-user'), +] \ No newline at end of file diff --git a/users/views.py b/users/views.py new file mode 100644 index 0000000..419570c --- /dev/null +++ b/users/views.py @@ -0,0 +1,33 @@ +from django.shortcuts import render +from rest_framework import generics, permissions +from rest_framework.response import Response +from rest_framework.decorators import api_view +from .models import User +from .serializers import UserSerializer + + +class UserListView(generics.ListAPIView): + """ + API view to retrieve list of users. + """ + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + +class UserDetailView(generics.RetrieveAPIView): + """ + API view to retrieve a single user. + """ + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [permissions.IsAuthenticated] + + +@api_view(['GET']) +def current_user(request): + """ + API endpoint to retrieve the current user's profile. + """ + serializer = UserSerializer(request.user) + return Response(serializer.data) \ No newline at end of file