import datetime import hashlib import datetime import pytz from django import forms from django.db import models from django.contrib.auth.models import AbstractUser from django.conf import settings from django.utils import timezone from django.utils.functional import cached_property from reversion import revisions as reversion from reversion.models import Version import string import random from collections import Counter from decimal import Decimal from django.core.exceptions import ValidationError from django.urls import reverse_lazy class Profile(AbstractUser): initials = models.CharField(max_length=5, unique=True, null=True, blank=False) phone = models.CharField(max_length=13, null=True, blank=True) api_key = models.CharField(max_length=40, blank=True, editable=False, null=True) is_approved = models.BooleanField(default=False) last_emailed = models.DateTimeField(blank=True, null=True) # Currently only populated by the admin approval email. TODO: Populate it each time we send any email, might need that... @classmethod def make_api_key(cls): size = 20 chars = string.ascii_letters + string.digits new_api_key = ''.join(random.choice(chars) for x in range(size)) return new_api_key @property def profile_picture(self): url = "" if settings.USE_GRAVATAR or settings.USE_GRAVATAR is None: url = "https://www.gravatar.com/avatar/" + hashlib.md5( self.email.encode('utf-8')).hexdigest() + "?d=wavatar&s=500" return url @property def name(self): name = self.get_full_name() if self.initials: name += ' "{}"'.format(self.initials) return name @property def latest_events(self): return self.event_mic.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic') @classmethod def admins(cls): return Profile.objects.filter(email__in=[y for x in settings.ADMINS for y in x]) @classmethod def users_awaiting_approval_count(cls): return Profile.objects.filter(models.Q(is_approved=False)).count() def __str__(self): return self.name # TODO move to versioning - currently get import errors with that class RevisionMixin(object): @property def is_first_version(self): versions = Version.objects.get_for_object(self) return len(versions) == 1 @property def current_version(self): version = Version.objects.get_for_object(self).select_related('revision').first() return version @property def last_edited_at(self): version = self.current_version if version is None: return None return version.revision.date_created @property def last_edited_by(self): version = self.current_version if version is None: return None return version.revision.user @property def current_version_id(self): version = self.current_version if version is None: return None return "V{0} | R{1}".format(version.pk, version.revision.pk) class Person(models.Model, RevisionMixin): name = models.CharField(max_length=50) phone = models.CharField(max_length=15, blank=True, null=True) email = models.EmailField(blank=True, null=True) address = models.TextField(blank=True, null=True) notes = models.TextField(blank=True, null=True) def __str__(self): string = self.name if self.notes is not None: if len(self.notes) > 0: string += "*" return string @property def organisations(self): o = [] for e in Event.objects.filter(person=self).select_related('organisation'): if e.organisation: o.append(e.organisation) # Count up occurances and put them in descending order c = Counter(o) stats = c.most_common() return stats @property def latest_events(self): return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic') def get_absolute_url(self): return reverse_lazy('person_detail', kwargs={'pk': self.pk}) class Organisation(models.Model, RevisionMixin): name = models.CharField(max_length=50) phone = models.CharField(max_length=15, blank=True, null=True) email = models.EmailField(blank=True, null=True) address = models.TextField(blank=True, null=True) notes = models.TextField(blank=True, null=True) union_account = models.BooleanField(default=False) def __str__(self): string = self.name if self.notes is not None: if len(self.notes) > 0: string += "*" return string @property def persons(self): p = [] for e in Event.objects.filter(organisation=self).select_related('person'): if e.person: p.append(e.person) # Count up occurances and put them in descending order c = Counter(p) stats = c.most_common() return stats @property def latest_events(self): return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic') def get_absolute_url(self): return reverse_lazy('organisation_detail', kwargs={'pk': self.pk}) class VatManager(models.Manager): def current_rate(self): return self.find_rate(timezone.now()) def find_rate(self, date): # return self.filter(startAt__lte=date).latest() try: return self.filter(start_at__lte=date).latest() except VatRate.DoesNotExist: r = VatRate r.rate = 0 return r @reversion.register class VatRate(models.Model, RevisionMixin): start_at = models.DateField() rate = models.DecimalField(max_digits=6, decimal_places=6) comment = models.CharField(max_length=255) objects = VatManager() @property def as_percent(self): return self.rate * 100 class Meta: ordering = ['-start_at'] get_latest_by = 'start_at' def __str__(self): return self.comment + " " + str(self.start_at) + " @ " + str(self.as_percent) + "%" class Venue(models.Model, RevisionMixin): name = models.CharField(max_length=255) phone = models.CharField(max_length=15, blank=True, null=True) email = models.EmailField(blank=True, null=True) three_phase_available = models.BooleanField(default=False) notes = models.TextField(blank=True, null=True) address = models.TextField(blank=True, null=True) def __str__(self): string = self.name if self.notes and len(self.notes) > 0: string += "*" return string @property def latest_events(self): return self.event_set.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic') def get_absolute_url(self): return reverse_lazy('venue_detail', kwargs={'pk': self.pk}) class EventManager(models.Manager): def current_events(self): events = self.filter( (models.Q(start_date__gte=timezone.now().date(), end_date__isnull=True, dry_hire=False) & ~models.Q( status=Event.CANCELLED)) | # Starts after with no end (models.Q(end_date__gte=timezone.now().date(), dry_hire=False) & ~models.Q( status=Event.CANCELLED)) | # Ends after (models.Q(dry_hire=True, start_date__gte=timezone.now().date()) & ~models.Q( status=Event.CANCELLED)) | # Active dry hire (models.Q(dry_hire=True, checked_in_by__isnull=True) & ( models.Q(status=Event.BOOKED) | models.Q(status=Event.CONFIRMED))) | # Active dry hire GT models.Q(status=Event.CANCELLED, start_date__gte=timezone.now().date()) # Canceled but not started ).order_by('start_date', 'end_date', 'start_time', 'end_time', 'meet_at').select_related('person', 'organisation', 'venue', 'mic') return events def events_in_bounds(self, start, end): events = self.filter( (models.Q(start_date__gte=start.date(), start_date__lte=end.date())) | # Start date in bounds (models.Q(end_date__gte=start.date(), end_date__lte=end.date())) | # End date in bounds (models.Q(access_at__gte=start, access_at__lte=end)) | # Access at in bounds (models.Q(meet_at__gte=start, meet_at__lte=end)) | # Meet at in bounds (models.Q(start_date__lte=start, end_date__gte=end)) | # Start before, end after (models.Q(access_at__lte=start, start_date__gte=end)) | # Access before, start after (models.Q(access_at__lte=start, end_date__gte=end)) | # Access before, end after (models.Q(meet_at__lte=start, start_date__gte=end)) | # Meet before, start after (models.Q(meet_at__lte=start, end_date__gte=end)) # Meet before, end after ).order_by('start_date', 'end_date', 'start_time', 'end_time', 'meet_at').select_related('person', 'organisation', 'venue', 'mic') return events def rig_count(self): event_count = self.filter( (models.Q(start_date__gte=timezone.now().date(), end_date__isnull=True, dry_hire=False, is_rig=True) & ~models.Q( status=Event.CANCELLED)) | # Starts after with no end (models.Q(end_date__gte=timezone.now().date(), dry_hire=False, is_rig=True) & ~models.Q( status=Event.CANCELLED)) | # Ends after (models.Q(dry_hire=True, start_date__gte=timezone.now().date(), is_rig=True) & ~models.Q( status=Event.CANCELLED)) # Active dry hire ).count() return event_count @reversion.register(follow=['items']) class Event(models.Model, RevisionMixin): # Done to make it much nicer on the database PROVISIONAL = 0 CONFIRMED = 1 BOOKED = 2 CANCELLED = 3 EVENT_STATUS_CHOICES = ( (PROVISIONAL, 'Provisional'), (CONFIRMED, 'Confirmed'), (BOOKED, 'Booked'), (CANCELLED, 'Cancelled'), ) name = models.CharField(max_length=255) person = models.ForeignKey('Person', null=True, blank=True, on_delete=models.CASCADE) organisation = models.ForeignKey('Organisation', blank=True, null=True, on_delete=models.CASCADE) venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE) description = models.TextField(blank=True, null=True) notes = models.TextField(blank=True, null=True) status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL) dry_hire = models.BooleanField(default=False) is_rig = models.BooleanField(default=True) based_on = models.ForeignKey('Event', on_delete=models.SET_NULL, related_name='future_events', blank=True, null=True) # Timing start_date = models.DateField() start_time = models.TimeField(blank=True, null=True) end_date = models.DateField(blank=True, null=True) end_time = models.TimeField(blank=True, null=True) access_at = models.DateTimeField(blank=True, null=True) meet_at = models.DateTimeField(blank=True, null=True) meet_info = models.CharField(max_length=255, blank=True, null=True) # Crew management checked_in_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_checked_in', blank=True, null=True, on_delete=models.CASCADE) mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True, verbose_name="MIC", on_delete=models.CASCADE) # Monies payment_method = models.CharField(max_length=255, blank=True, null=True) payment_received = models.CharField(max_length=255, blank=True, null=True) purchase_order = models.CharField(max_length=255, blank=True, null=True, verbose_name='PO') collector = models.CharField(max_length=255, blank=True, null=True, verbose_name='collected by') # Authorisation request details auth_request_by = models.ForeignKey('Profile', null=True, blank=True, on_delete=models.CASCADE) auth_request_at = models.DateTimeField(null=True, blank=True) auth_request_to = models.EmailField(null=True, blank=True) # Calculated values """ EX Vat """ @property def sum_total(self): total = EventItem.objects.filter(event=self).aggregate( sum_total=models.Sum(models.F('cost') * models.F('quantity'), output_field=models.DecimalField(max_digits=10, decimal_places=2)) )['sum_total'] if total: return total return Decimal("0.00") @cached_property def vat_rate(self): return VatRate.objects.find_rate(self.start_date) @property def vat(self): return Decimal(self.sum_total * self.vat_rate.rate).quantize(Decimal('.01')) """ Inc VAT """ @property def total(self): return Decimal(self.sum_total + self.vat).quantize(Decimal('.01')) @property def cancelled(self): return (self.status == self.CANCELLED) @property def confirmed(self): return (self.status == self.BOOKED or self.status == self.CONFIRMED) @property def authorised(self): return not self.internal and self.purchase_order or self.authorisation.amount == self.total @property def has_start_time(self): return self.start_time is not None @property def has_end_time(self): return self.end_time is not None @property def earliest_time(self): """Finds the earliest time defined in the event - this function could return either a tzaware datetime, or a naiive date object""" # Put all the datetimes in a list datetime_list = [] if self.access_at: datetime_list.append(self.access_at) if self.meet_at: datetime_list.append(self.meet_at) # If there is no start time defined, pretend it's midnight startTimeFaked = False if self.has_start_time: startDateTime = datetime.datetime.combine(self.start_date, self.start_time) else: startDateTime = datetime.datetime.combine(self.start_date, datetime.time(00, 00)) startTimeFaked = True # timezoneIssues - apply the default timezone to the naiive datetime tz = pytz.timezone(settings.TIME_ZONE) startDateTime = tz.localize(startDateTime) datetime_list.append(startDateTime) # then add it to the list earliest = min(datetime_list).astimezone(tz) # find the earliest datetime in the list # if we faked it & it's the earliest, better own up if startTimeFaked and earliest == startDateTime: return self.start_date return earliest @property def latest_time(self): """Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object""" tz = pytz.timezone(settings.TIME_ZONE) endDate = self.end_date if endDate is None: endDate = self.start_date if self.has_end_time: endDateTime = datetime.datetime.combine(endDate, self.end_time) tz = pytz.timezone(settings.TIME_ZONE) endDateTime = tz.localize(endDateTime) return endDateTime else: return endDate @property def internal(self): return self.organisation and self.organisation.union_account objects = EventManager() def get_absolute_url(self): return reverse_lazy('event_detail', kwargs={'pk': self.pk}) def __str__(self): return str(self.pk) + ": " + self.name def clean(self): if self.end_date and self.start_date > self.end_date: raise ValidationError('Unless you\'ve invented time travel, the event can\'t finish before it has started.') startEndSameDay = not self.end_date or self.end_date == self.start_date hasStartAndEnd = self.has_start_time and self.has_end_time if startEndSameDay and hasStartAndEnd and self.start_time > self.end_time: raise ValidationError('Unless you\'ve invented time travel, the event can\'t finish before it has started.') if self.access_at is not None: if self.access_at.date() > self.start_date: raise ValidationError('Regardless of what some clients might think, access time cannot be after the event has started.') elif self.start_time is not None and self.start_date == self.access_at.date() and self.access_at.time() > self.start_time: raise ValidationError('Regardless of what some clients might think, access time cannot be after the event has started.') def save(self, *args, **kwargs): """Call :meth:`full_clean` before saving.""" self.full_clean() super(Event, self).save(*args, **kwargs) class EventItem(models.Model): event = models.ForeignKey('Event', related_name='items', blank=True, on_delete=models.CASCADE) name = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) quantity = models.IntegerField() cost = models.DecimalField(max_digits=10, decimal_places=2) order = models.IntegerField() @property def total_cost(self): return self.cost * self.quantity class Meta: ordering = ['order'] def __str__(self): return str(self.event.pk) + "." + str(self.order) + ": " + self.event.name + " | " + self.name @reversion.register class EventAuthorisation(models.Model, RevisionMixin): event = models.OneToOneField('Event', related_name='authorisation', on_delete=models.CASCADE) email = models.EmailField() name = models.CharField(max_length=255) uni_id = models.CharField(max_length=10, blank=True, null=True, verbose_name="University ID") account_code = models.CharField(max_length=50, blank=True, null=True) amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="authorisation amount") sent_by = models.ForeignKey('Profile', on_delete=models.CASCADE) def get_absolute_url(self): return reverse_lazy('event_detail', kwargs={'pk': self.event.pk}) @property def activity_feed_string(self): return str("N%05d" % self.event.pk + ' (requested by ' + self.sent_by.initials + ')') @reversion.register(follow=['payment_set']) class Invoice(models.Model): event = models.OneToOneField('Event', on_delete=models.CASCADE) invoice_date = models.DateField(auto_now_add=True) void = models.BooleanField(default=False) @property def sum_total(self): return self.event.sum_total @property def total(self): return self.event.total @property def payment_total(self): total = self.payment_set.aggregate(total=models.Sum('amount'))['total'] if total: return total return Decimal("0.00") @property def balance(self): return self.sum_total - self.payment_total @property def is_closed(self): return self.balance == 0 or self.void def __str__(self): return "%i: %s (%.2f)" % (self.pk, self.event, self.balance) class Meta: ordering = ['-invoice_date'] class Payment(models.Model): CASH = 'C' INTERNAL = 'I' EXTERNAL = 'E' SUCORE = 'SU' ADJUSTMENT = 'T' METHODS = ( (CASH, 'Cash'), (INTERNAL, 'Internal'), (EXTERNAL, 'External'), (SUCORE, 'SU Core'), (ADJUSTMENT, 'TEC Adjustment'), ) invoice = models.ForeignKey('Invoice', on_delete=models.CASCADE) date = models.DateField() amount = models.DecimalField(max_digits=10, decimal_places=2, help_text='Please use ex. VAT') method = models.CharField(max_length=2, choices=METHODS, null=True, blank=True) def __str__(self): return "%s: %d" % (self.get_method_display(), self.amount) @reversion.register class RiskAssessment(models.Model, RevisionMixin): event = models.OneToOneField('Event', on_delete=models.CASCADE) # General nonstandard_equipment = models.BooleanField(help_text="Does the event require any hired in equipment or use of equipment that is not covered by TEC's standard risk assessments and method statements?") nonstandard_use = models.BooleanField(help_text="Are TEC using their equipment in a way that is abnormal?
i.e. Not covered by TECs standard health and safety documentation") contractors = models.BooleanField(help_text="Are you using any external contractors?") other_companies = models.BooleanField(help_text="Are TEC working with any other companies on site?") crew_fatigue = models.BooleanField(help_text="Is crew fatigue likely to be a risk at any point during this event?") general_notes = models.TextField(blank=True, null=True, help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?") # Power big_power = models.BooleanField(help_text="Does the event require larger power supplies than 13A or 16A single phase wall sockets, or draw more than 20A total current?") power_mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='power_mic', blank=True, null=True, verbose_name="Power MIC", on_delete=models.CASCADE, help_text="Who is the Power MIC?") generators = models.BooleanField(help_text="Will generators be used?") other_companies_power = models.BooleanField(help_text="Will TEC be supplying power to any other companies?") nonstandard_equipment_power = models.BooleanField(help_text="Does the power plan require the use of any power equipment (distros, dimmers, motor controllers, etc.) that does not belong to TEC?") multiple_electrical_environments = models.BooleanField(help_text="Will the electrical installation occupy more than one electrical environment?") power_notes = models.TextField(blank=True, null=True, help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?") # Sound noise_monitoring = models.BooleanField(help_text="Does the event require noise monitoring or any non-standard procedures in order to comply with health and safety legislation or site rules?") sound_notes = models.TextField(blank=True, null=True, help_text="Did you have to consult a supervisor about any of the above? If so who did you consult and what was the outcome?") # Site known_venue = models.BooleanField(help_text="Is the event in a venue that you and/or TEC have experience working in?") safe_loading = models.BooleanField(help_text="Is there a safe load in/out?
e.g. sufficient lighting, flat, not in a crowded area etc.") safe_storage = models.BooleanField(help_text="Are there areas to safely store equipment?") area_outside_of_control = models.BooleanField(help_text="Is any part of the work area out of TEC's direct control or openly accessible during the build or breakdown period?") barrier_required = models.BooleanField(help_text="Is there a requirement for TEC to provide any barrier for security or protection of persons/equipment?") nonstandard_emergency_procedure = models.BooleanField(help_text="Does the emergency procedure for the event differ from TEC's standard procedures?") # Structures special_structures = models.BooleanField(help_text="Does the event require use of winch stands, motors, MPT Towers, or staging?") persons_responsible_structures = models.TextField(blank=True, null=True, help_text="Who are the persons on site responsible for their use?") suspended_structures = models.BooleanField(help_text="Are any structures (excluding projector screens and IWBs) being suspended from TEC's structures?") # Blimey that was a lot of options reviewed_at = models.DateTimeField(null=True) reviewed_by = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, verbose_name="Reviewer", on_delete=models.CASCADE) def clean(self): errdict = {} for field in RiskAssessment._meta.fields: if field.__class__ == forms.BooleanField and self.field is None: errdict[field.name] = ["This field is required"] if errdict != {}: # If there was an error when validation raise ValidationError(errdict) @property def activity_feed_string(self): return str(self.event) def get_absolute_url(self): return reverse_lazy('ra_detail', kwargs={'pk': self.pk}) def __str__(self): return "%i - %s" % (self.pk, self.event) @reversion.register class EventChecklist(models.Model, RevisionMixin): event = models.OneToOneField('Event', on_delete=models.CASCADE) # General power_mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='checklist', verbose_name="Power MIC", on_delete=models.CASCADE, help_text="Who is the Power MIC?") vehicles = models.JSONField(help_text="List vehicles and their drivers", default=dict, null=False) # Safety Checks safe_parking = models.BooleanField(help_text="Vehicles parked safely?
(does not obstruct venue access)") safe_packing = models.BooleanField(help_text="Equipment packed away safely?
(including flightcases)") exits = models.BooleanField(help_text="Emergency exits clear?") trip_hazard = models.BooleanField(help_text="Appropriate barriers around kit and cabling secured?") warning_signs = models.BooleanField(help_text="Warning signs in place?
(strobe, smoke, power etc.)") ear_plugs = models.BooleanField(help_text="Ear plugs issued to crew where needed?") hs_location = models.CharField(max_length=255, help_text="Location of Safety Bag/Box") extinguishers_location = models.CharField(max_length=255, help_text="Location of fire extinguishers") # Crew Record TODO # Small Electrical Checks rcds = models.BooleanField(help_text="RCDs installed where needed and tested?") supply_test = models.BooleanField(help_text="Electrical supplies tested?
(using socket tester)") earthing = models.BooleanField(help_text="Equipment appropriately earthed?
(truss, stage, etc)") pat = models.BooleanField(help_text="All equipment in PAT period?") medium_event = models.BooleanField() # Medium Electrical Checks @property def activity_feed_string(self): return str(self.event) def get_absolute_url(self): return reverse_lazy('ec_detail', kwargs={'pk': self.pk}) def __str__(self): return "%i - %s" % (self.pk, self.event)