Source code for vms.models

import datetime
import decimal
import logging
import uuid

import email_utils
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.crypto import get_random_string
from django.utils.text import slugify
from django.utils.translation import ugettext_lazy as _, ugettext

from vms import id_utils, managers


logger = logging.getLogger(__name__)


[docs]def generate_slug(value, queryset, slug_dest='slug'): """ Generate and save a unique slug for the provided instance. Args: value: The value to slugify. queryset: The queryset to search to ensure the generated slug is unique. slug_dest: The name of the attribute on the instance that the slug is saved to. """ logger.debug('Generating unique slug for value %s', value) base = slugify(value)[:settings.SLUG_LENGTH] suffix = '' while queryset.filter(**{slug_dest: f'{base}{suffix}'}).exists(): logger.debug('Slug %s%s is not unique', base, suffix) suffix = f'-{get_random_string(settings.SLUG_KEY_LENGTH)}' return f'{base}{suffix}'
[docs]def generate_token(): """ Generate a random token. Returns: A random 16-character alphanumeric string. """ return get_random_string(16)
[docs]class Client(models.Model): """ A client is a company that employees perform work for. """ id = models.PositiveIntegerField( blank=True, editable=False, help_text=_( 'A unique numeric identifier for the client. If not specified, it ' 'will be randomly generated.' ), primary_key=True, unique=True, verbose_name=_('client ID'), ) email = models.EmailField( help_text=_('The primary email address for the client.'), verbose_name=_('primary email address'), ) name = models.CharField( help_text=_('The name of the client company.'), max_length=100, verbose_name=_('name'), ) notes = models.TextField( blank=True, help_text=_('Additional information about the client.'), verbose_name=_('notes'), ) phone_number = models.CharField( blank=True, help_text=_('The phone number that the client can be reached at.'), max_length=30, verbose_name=_('phone number'), ) slug = models.SlugField( help_text=_('The URL slug used to look up the client.'), max_length=settings.SLUG_LENGTH_TOTAL, verbose_name=_('slug'), ) time_created = models.DateTimeField( auto_now_add=True, help_text=_('The time the client was created.'), verbose_name=_('creation time'), ) time_updated = models.DateTimeField( auto_now=True, help_text=_("The last time the client's information was updated."), verbose_name=_('last update time'), ) class Meta: ordering = ('name', 'time_created',) verbose_name = _('client') verbose_name_plural = _('clients') def __str__(self): """ Get a user readable string describing the instance. Returns: The client's name. """ return self.name
[docs] def clean(self): """ Generate a unique ID and slug if necessary. """ super().clean() if not self.id: self.id = id_utils.generate_unique_id( settings.CLIENT_ID_LENGTH, self.__class__.objects.all(), ) if not self.slug: self.slug = generate_slug(self.name, self.__class__.objects.all())
[docs] def get_absolute_url(self): """ Get the URL of the instance's detail view. Returns: The absolute URL of the instance's detail view. """ return reverse( 'vms:client-detail', kwargs={'client_slug': self.slug}, )
@property def job_list_url(self): """ Get the URL of the client's job list. Returns: The URL of the view where all the client's jobs are listed. """ return reverse( 'vms:client-job-list', kwargs={'client_slug': self.slug}, ) @property def unapproved_time_record_list_url(self): """ Get the URL of the client's unapproved time record list. Returns: The absolute URL of the view to list the client's unapproved time records. """ return reverse( 'vms:unapproved-time-record-list', kwargs={'client_slug': self.slug}, )
[docs]class ClientAdmin(models.Model): """ A link between a user and a client that grants the linked user admin permissions on the associated client company. """ client = models.ForeignKey( 'vms.Client', help_text=_('The client company that the user has admin rights to.'), on_delete=models.CASCADE, related_name='admins', related_query_name='admin', verbose_name=_('client'), ) time_created = models.DateTimeField( auto_now_add=True, help_text=_('The time the admin was created at.'), verbose_name=_('creation time'), ) user = models.ForeignKey( settings.AUTH_USER_MODEL, help_text=_('The user who has admin rights on the linked client.'), on_delete=models.CASCADE, related_name='client_admins', related_query_name='client_admin', verbose_name=_('admin user'), ) class Meta: ordering = ('client__name', 'time_created') unique_together = ('client', 'user') verbose_name = _('client administrator') verbose_name_plural = _('client administrators') def __str__(self): """ Get a user readable string describing the instance. Returns: A string containing the names of the linked user and client. """ return f'{self.client.name} admin {self.user.name}'
[docs]class ClientAdminInvite(models.Model): """ An invitation for a user to become a client admin. """ client = models.ForeignKey( 'vms.Client', help_text=_('The client that the invitee will become an admin of.'), on_delete=models.CASCADE, related_name='admin_invites', related_query_name='admin_invite', verbose_name=_('client'), ) email = models.EmailField( help_text=_('The email address to send the invitation to.'), verbose_name=_('email'), ) time_created = models.DateTimeField( auto_now_add=True, help_text=_('The time the invitation was created at.'), verbose_name=_('creation time'), ) token = models.CharField( blank=True, default=generate_token, help_text=_('A unique token used to accept the invitation.'), max_length=16, unique=True, verbose_name=_('token'), ) class Meta: ordering = ('time_created',) verbose_name = _('client administrator invitation') verbose_name_plural = _('client administrator invitations') def __str__(self): """ Get a user readable string describing the instance. Returns: A string containing the email address the invite was sent to as well as the name of the client company. """ return f'Admin invite for {self.email} from {self.client}'
[docs] def accept(self, user): """ Accept the invitation. Args: user: The user who accepted the invitation. Returns: The created client admin. """ admin = ClientAdmin.objects.create(client=self.client, user=user) logger.info( 'Create new client administrator from invite: %r', admin, ) logger.debug('Deleting admin invitation %r after being accepted', self) self.delete() return admin
@property def accept_url(self): """ Returns: The absolute URL of the view to accept the invite. """ return reverse( 'vms:client-admin-invite-accept', kwargs={'client_slug': self.client.slug, 'token': self.token}, )
[docs] def send(self, request): """ Send an invitation message to the email address associated with the instance. Args: request: The request that was made to trigger the send. This is used to build the full URL for accepting the invite. """ email_utils.send_email( context={ 'accept_url': f'{request.get_host()}{self.accept_url}', 'client': self.client, }, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=[self.email], subject=ugettext('Client Administrator Invitation'), template_name='vms/emails/client-admin-invite', ) logger.info('Sent client admin invitation to %s', self.email)
[docs]class ClientJob(models.Model): """ A type of task that can be worked on for a specific client. """ client = models.ForeignKey( 'vms.Client', help_text=_('The client that the job is performed for.'), on_delete=models.CASCADE, related_name='jobs', related_query_name='job', verbose_name=_('client'), ) description = models.TextField( blank=True, help_text=_('More details about the job.'), verbose_name=_('description'), ) name = models.CharField( help_text=_('The name of the job'), max_length=100, verbose_name=_('name'), ) pay_rate = models.DecimalField( decimal_places=2, max_digits=11, ) slug = models.SlugField( help_text=_('A unique slug that can be used to retrieve the job.'), max_length=100, verbose_name=_('slug'), ) class Meta: ordering = ('name',) unique_together = ('client', 'slug') verbose_name = _('client job') verbose_name_plural = _('client jobs') def __str__(self): """ Get a user readable string describing the instance. Returns: The job name. """ return self.name
[docs] def clean(self): """ Generate a slug for the instance if necessary. """ super().clean() if not self.slug: self.slug = slugify(self.name)
[docs] def clean_fields(self, exclude=None): """ Ensure the name of the instance is not too similar to other jobs owned by the same client. Args: exclude: An optional list of fields to exclude from validation. Defaults to ``None``. """ super().clean_fields(exclude) if exclude is not None and 'name' in exclude: return slug = slugify(self.name) queryset = self.__class__.objects.filter( client=self.client, slug=slug, ).exclude(id=self.id) if queryset.exists(): other = queryset.get() logger.info( 'Client job %r failed unique validation for client %r', self, self.client, ) message = ugettext( "The name '%(name)s' is too similar to the name of the " "existing job '%(existing_name)s'." ) % { 'existing_name': other.name, 'name': self.name, } raise ValidationError({'name': message})
[docs] def get_absolute_url(self): """ Get the absolute URL of the instance's detail view. Returns: The absolute URL of the instance's detail view. """ return reverse( 'vms:client-job-detail', kwargs={'client_slug': self.client.slug, 'job_slug': self.slug}, )
[docs]class Employee(models.Model): """ An employee working for a specific company. """ approved_by = models.ForeignKey( 'vms.ClientAdmin', blank=True, help_text=_('The admin who approved the employee.'), null=True, on_delete=models.SET_NULL, related_name='approved_employees', related_query_name='approved_employee', verbose_name=_('approved by'), ) client = models.ForeignKey( 'vms.Client', help_text=_('The client company the employee works for.'), on_delete=models.CASCADE, related_name='employees', related_query_name='employee', verbose_name=_('client'), ) employee_id = models.PositiveIntegerField( blank=True, db_index=True, help_text=_( 'A unique number identifying the employee within the client ' 'company they work for.' ), verbose_name=_('employee ID'), ) is_active = models.BooleanField( default=False, help_text=_( 'A boolean indicating if this user is currently active. Inactive ' 'employees cannot log any working hours.' ), verbose_name=_('is active'), ) supervisor = models.ForeignKey( 'vms.ClientAdmin', blank=True, help_text=_( "The client administrator who can approve the user's hours.", ), null=True, on_delete=models.SET_NULL, related_name='employees', related_query_name='employee', verbose_name=_('supervisor'), ) staffing_agency = models.ForeignKey( 'vms.StaffingAgency', help_text=_('The staffing agency that hired the employee.'), on_delete=models.CASCADE, related_name='client_employees', related_query_name='client_employee', verbose_name=_('staffing agency'), ) time_approved = models.DateTimeField( blank=True, help_text=_('The time that the employee was approved.'), null=True, verbose_name=_('approval time'), ) time_created = models.DateTimeField( auto_now_add=True, help_text=_('The time the employee was created.'), verbose_name=_('time created'), ) time_updated = models.DateTimeField( auto_now=True, help_text=_('The time the employee was last updated.'), verbose_name=_('time updated'), ) user = models.ForeignKey( settings.AUTH_USER_MODEL, help_text=_('The user account the employee is attached to.'), on_delete=models.CASCADE, related_name='employees', related_query_name='employee', verbose_name=_('user'), ) class Meta: ordering = ('time_created',) verbose_name = _('employee') verbose_name_plural = _('employees') def __str__(self): """ Get a user readable string describing the instance. Returns: A string containing the name of the user who owns the employee instance. """ return ( f'{self.user.name} (Hired by {self.staffing_agency})' )
[docs] def approve(self, admin): """ Approve the employee's request to join the client. Args: admin: The client admin who approved the request. """ if self.time_approved is not None: logger.warning('Approving previously approved employee %r', self) return self.approved_by = admin self.is_active = True self.time_approved = timezone.now() self.save()
@property def approve_url(self): """ Returns: The absolute URL of the view used to approve the employee. """ return reverse( 'vms:employee-approval', kwargs={ 'client_slug': self.client.slug, 'employee_id': self.employee_id, }, ) @property def clock_in_url(self): """ Returns: The absolute URL of the view used to clock in the employee. """ return reverse( 'vms:clock-in', kwargs={ 'client_slug': self.client.slug, 'employee_id': self.employee_id, }, ) @property def clock_out_url(self): """ Returns: The absolute URL of the view used to clock out the employee. """ return reverse( 'vms:clock-out', kwargs={ 'client_slug': self.client.slug, 'employee_id': self.employee_id, }, ) @property def is_clocked_in(self): """ Determine if the employee is currently clocked in. Returns: A boolean indicating if the employee is clocked in. """ return self.time_records.filter(time_end=None).exists()
[docs] def get_absolute_url(self): """ Returns: The URL of the view for an employee of a client """ return reverse( 'vms:employee-dash', kwargs={ 'client_slug': self.client.slug, 'employee_id': self.employee_id, }, )
@property def total_time(self): """ Get the total time that the employee has logged. Returns: The total time the employee has worked, in seconds. """ return self.time_records.total_time().total_seconds()
[docs] def save(self, *args, **kwargs): """ Save the employee and generate an ID for them if necessary. """ if not self.employee_id: query = self.__class__.objects.filter(client=self.client) self.employee_id = id_utils.generate_unique_id( settings.EMPLOYEE_ID_LENGTH, query, queryset_attr='employee_id', ) super().save(*args, **kwargs)
[docs] def validate_unique(self, exclude=None): """ Validate that the employee's ID is unique to the company they work for. Args: exclude: An optional list of field names to exclude from the check. """ super().validate_unique(exclude) if exclude is not None and 'employee_id' in exclude: return queryset = self.__class__.objects.filter( client=self.client, employee_id=self.employee_id, ) if self.id: queryset = queryset.exclude(id=self.id) if queryset.exists(): raise ValidationError( 'Employee IDs must be unique within a client company.', )
[docs]class StaffingAgency(models.Model): """ A company that provides employees to clients. """ email = models.EmailField( help_text=_('The primary email address for the agency.'), verbose_name=_('primary email address'), ) name = models.CharField( help_text=_('The name of the staffing agency.'), max_length=100, verbose_name=_('name'), ) notes = models.TextField( blank=True, help_text=_('Additional information about the staffing agency.'), verbose_name=_('notes'), ) phone_number = models.CharField( blank=True, help_text=_('The phone number that the agency can be reached at.'), max_length=30, verbose_name=_('phone number'), ) slug = models.SlugField( help_text=_('The URL slug used to look up the staffing agency.'), max_length=settings.SLUG_LENGTH_TOTAL, verbose_name=_('slug'), ) time_created = models.DateTimeField( auto_now_add=True, help_text=_('The time the agency was created.'), verbose_name=_('creation time'), ) time_updated = models.DateTimeField( auto_now=True, help_text=_("The last time the agency's information was updated."), verbose_name=_('last update time'), ) class Meta: ordering = ('name', 'time_created',) verbose_name = _('staffing agency') verbose_name_plural = _('staffing agencies')
[docs] def save(self, *args, **kwargs): """ Save the agency, creating a slug if necessary. Args: *args: Positional arguments to pass to the original save method. **kwargs: Keyword arguments to pass to the original save method. """ # TODO: Fix the race condition here # There is a race condition here where the slug is generated and # unique among the current clients, but a new client with the # same slug is saved before we get to the save call below. if not self.slug: self.slug = generate_slug(self.name, Client.objects.all()) super().save(*args, **kwargs)
def __str__(self): """ Get a user readable string describing the instance. Returns: The agency's name. """ return self.name
[docs] def get_absolute_url(self): """ Returns: The absolute URL of the instance's detail view. """ return reverse( 'vms:staffing-agency-view', kwargs={'staffing_agency_slug': self.slug}, )
[docs]class StaffingAgencyAdmin(models.Model): """ A link between a user and a staffing agency that grants the linked user admin permissions on the associated staffing agency. """ agency = models.ForeignKey( 'vms.StaffingAgency', help_text=_('The staffing agency that the user has admin rights to.'), on_delete=models.CASCADE, related_name='admins', related_query_name='admin', verbose_name=_('staffing agency'), ) time_created = models.DateTimeField( auto_now_add=True, help_text=_('The time the admin was created at.'), verbose_name=_('creation time'), ) user = models.ForeignKey( settings.AUTH_USER_MODEL, help_text=_('The user who has admin rights on the linked agency.'), on_delete=models.CASCADE, related_name='agency_admins', related_query_name='agency_admin', verbose_name=_('admin user'), ) class Meta: ordering = ('agency__name', 'user__name', 'time_created') unique_together = ('agency', 'user') verbose_name = _('staffing agency administrator') verbose_name_plural = _('staffing agency administrators') def __str__(self): """ Get a user readable string describing the instance. Returns: A string containing the names of the linked user and agency. """ return f'{self.agency.name} admin {self.user.name}'
[docs]class StaffingAgencyEmployee(models.Model): """ An employee who is contracted out by a staffing agency. """ agency = models.ForeignKey( 'vms.StaffingAgency', help_text=_( 'The staffing agency the employee works for.' ), on_delete=models.CASCADE, related_name='employees', related_query_name='employee', verbose_name=_('staffing agency'), ) approved_by = models.ForeignKey( 'vms.StaffingAgencyAdmin', blank=True, help_text=_('The admin who accepted the employee.'), null=True, on_delete=models.SET_NULL, related_name='approved_employees', ) id = models.UUIDField( default=uuid.uuid4, help_text=_('A unique identifier for the employee.'), primary_key=True, verbose_name=_('ID'), ) is_approved = models.BooleanField( default=False, help_text=_( 'A boolean indicating if the employee has been accepted into the ' 'agency.' ), verbose_name=_('is approved'), ) time_approved = models.DateTimeField( blank=True, help_text=_('The time that the request was approved.'), null=True, verbose_name=_('approval time'), ) time_created = models.DateTimeField( auto_now_add=True, help_text=_('The time that the request was submitted.'), verbose_name=_('creation time'), ) user = models.ForeignKey( settings.AUTH_USER_MODEL, help_text=_('The user that works for the agency.'), on_delete=models.CASCADE, related_name='staffing_agency_employees', related_query_name='staffing_agency_employee', verbose_name=_('user'), ) class Meta: unique_together = ('agency', 'user') verbose_name = _('staffing agency employee') verbose_name_plural = _('staffing agency employees') def __str__(self): """ Get a user readable string describing the instance. Returns: A string identifying the user and staffing agency attached to the employee record. """ return ( f"{self.user.name} contracted by {self.agency.name}" )
[docs] def approve(self, admin): """ Mark the staffing agency employee as approved. Args: admin: The staffing agency administrator who approved the employee. """ if self.is_approved: logger.warning( 'Approving already approved staffing agency employee: %r', self, ) self.approved_by = admin self.is_approved = True self.time_approved = timezone.now() self.save() logger.info('Approved staffing agency employee: %r', self)
[docs] def get_absolute_url(self): """ Get the URL of the staffing agency's employee. Returns: The URL of the view where the staffing agency's employee information is listed. """ return reverse( 'vms:staffing-agency-employee', kwargs={'staffing_agency_slug': self.agency.slug, 'employee_id': self.id},)
[docs]class TimeRecord(models.Model): """ A record marking a work period for an employee. """ employee = models.ForeignKey( 'vms.Employee', help_text=_('The employee who worked during this time period.'), on_delete=models.CASCADE, related_name='time_records', related_query_name='time_record', verbose_name=_('employee'), ) id = models.UUIDField( default=uuid.uuid4, help_text=_('A unique identifier for the time record.'), primary_key=True, unique=True, verbose_name=_('ID'), ) job = models.ForeignKey( 'vms.ClientJob', help_text=_('The job type that the employee worked on.'), null=True, on_delete=models.SET_NULL, related_name='time_records', related_query_name='time_record', verbose_name=_('client job'), ) pay_rate = models.DecimalField( decimal_places=2, help_text=_('The hourly pay for the time record.'), max_digits=11, verbose_name=_('pay rate'), ) time_end = models.DateTimeField( blank=True, help_text=_('The ending time of the work period.'), null=True, verbose_name=_('end time'), ) time_start = models.DateTimeField( default=timezone.now, help_text=_('The start time of the work period.'), verbose_name=_('start time'), ) # Use our custom manager objects = managers.TimeRecordManager() class Meta: ordering = ('time_start',) verbose_name = _('time record') verbose_name_plural = _('time records') def __repr__(self): """ Get a string representation of the instance. Returns: A string containing the information required to reconstruct the time record. """ return ( f'TimeRecord(' f'id={self.id!r}, ' f'job_id={self.job.id if self.job else None!r}, ' f'employee_id={self.employee.id!r}, ' f'time_start={self.time_start!r}, ' f'time_end={self.time_end!r})' ) def __str__(self): """ Get a user readable string describing the instance. Returns: A string describing the time record including the start and end times. """ st = self.time_start if self.time_end: et = self.time_end if self.time_start.date() == self.time_end.date(): return ( f'Time Record from {st:%I:%M %p} to ' f'{et:%I:%M %p} on {st:%m/%d/%Y}.' ) else: return ( f'Time Record from {st:%I:%M %p on %m/%d/%Y}, ' f'{et:%I:%M %p on %m/%d/%Y}.' ) return f'Time Record starting at {st:%I:%M %p} on {st:%m/%d/%Y}.' @property def approval_url(self): """ Get the URL of the view used to approve the time record. Returns: The absolute URL of the view used to approve the time record. """ return reverse( 'vms:time-record-approve', kwargs={'time_record_id': self.id}, ) @property def is_approved(self): """ Determine if the time record is approved. Returns: A boolean indicating if there is an approval record for the time record. """ return hasattr(self, 'approval') @property def total_time(self): """ Get the total time from this time record. Returns: The time delta between start time and end time or delta of 0 if no end time. """ if self.time_end: return self.time_end - self.time_start return datetime.timedelta(0) @property def projected_earnings(self): """ Get the projected earning from this time record. Returns: The total time multiplied by the pay rate for this job. """ hours_dec = self.total_time.total_seconds() / (60*60) return decimal.Decimal(hours_dec) * self.pay_rate
[docs]class TimeRecordApproval(models.Model): """ A record of the approval for a time record. """ id = models.UUIDField( default=uuid.uuid4, help_text=_('A unique identifier for the approval record.'), primary_key=True, unique=True, verbose_name=_('ID'), ) time_approved = models.DateTimeField( auto_now_add=True, help_text=_('The time that the associated time record was approved.'), verbose_name=_('approval time'), ) time_record = models.OneToOneField( 'vms.TimeRecord', help_text=_('The time record that is being approved.'), on_delete=models.CASCADE, related_name='approval', verbose_name=_('time record'), ) user = models.ForeignKey( settings.AUTH_USER_MODEL, help_text=_('The user who approved the time record.'), on_delete=models.CASCADE, related_name='time_record_approvals', related_query_name='time_record_approval', verbose_name=_('approving user'), ) class Meta: ordering = ('time_approved',) verbose_name = _('time record approval') verbose_name_plural = _('time record approvals') def __str__(self): """ Get a user readable string describing the instance. Returns: A string containing the name of the approved time record. """ return f'Approval for {self.time_record}'