import datetime import hashlib import random import string from collections import Counter from decimal import Decimal from urllib.parse import urlparse import pytz from django import forms from django.db.models import Q, F from django.conf import settings from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from reversion import revisions as reversion from reversion.models import Version from versioning.versioning import RevisionMixin def filter_by_pk(filt, query): # try and parse an int try: val = int(query) filt = filt | Q(pk=val) except: # noqa # not an integer pass return filt class Profile(AbstractUser): initials = models.CharField(max_length=5, null=True, blank=False) phone = models.CharField(max_length=13, blank=True, default='') api_key = models.CharField(max_length=40, blank=True, editable=False, default='') is_approved = models.BooleanField(default=False) # Currently only populated by the admin approval email. TODO: Populate it each time we send any email, might need that... last_emailed = models.DateTimeField(blank=True, null=True) dark_theme = models.BooleanField(default=False) is_supervisor = models.BooleanField(default=False) reversion_hide = True @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 += f' "{self.initials}"' return name @property def latest_events(self): return self.event_mic.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic', 'riskassessment', 'invoice').prefetch_related('checklists') @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 class ContactableManager(models.Manager): def search(self, query=None): qs = self.get_queryset() if query is not None: or_lookup = Q(name__icontains=query) | Q(email__icontains=query) | Q(address__icontains=query) | Q(notes__icontains=query) | Q( phone__startswith=query) | Q(phone__endswith=query) or_lookup = filter_by_pk(or_lookup, query) qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups return qs class Person(models.Model, RevisionMixin): name = models.CharField(max_length=50) phone = models.CharField(max_length=15, blank=True, default='') email = models.EmailField(blank=True, default='') address = models.TextField(blank=True, default='') notes = models.TextField(blank=True, default='') objects = ContactableManager() 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('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, default='') email = models.EmailField(blank=True, default='') address = models.TextField(blank=True, default='') notes = models.TextField(blank=True, default='') union_account = models.BooleanField(default=False) objects = ContactableManager() 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('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): 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() reversion_hide = True @property def as_percent(self): return self.rate * 100 class Meta: ordering = ['-start_at'] get_latest_by = 'start_at' def __str__(self): return f"{self.comment} {self.start_at} @ {self.as_percent}%" class Venue(models.Model, RevisionMixin): name = models.CharField(max_length=255) phone = models.CharField(max_length=15, blank=True, default='') email = models.EmailField(blank=True, default='') three_phase_available = models.BooleanField(default=False) notes = models.TextField(blank=True, default='') address = models.TextField(blank=True, default='') objects = ContactableManager() 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('venue_detail', kwargs={'pk': self.pk}) class EventManager(models.Manager): def current_events(self): events = self.filter( (models.Q(start_date__gte=timezone.now(), 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()) & ~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()) # 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(), 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(), dry_hire=False, is_rig=True) & ~models.Q( status=Event.CANCELLED)) | # Ends after (models.Q(dry_hire=True, start_date__gte=timezone.now(), is_rig=True) & ~models.Q( status=Event.CANCELLED)) # Active dry hire ).count() return event_count def waiting_invoices(self): events = self.filter( ( models.Q(start_date__lte=datetime.date.today(), end_date__isnull=True) | # Starts before with no end models.Q(end_date__lte=datetime.date.today()) # Or has end date, finishes before ) & models.Q(invoice__isnull=True) & # Has not already been invoiced models.Q(is_rig=True) # Is a rig (not non-rig) ).order_by('start_date') \ .select_related('person', 'organisation', 'venue', 'mic') \ .prefetch_related('items') return events def search(self, query=None): qs = self.get_queryset() if query is not None: or_lookup = Q(name__icontains=query) | Q(description__icontains=query) | Q(notes__icontains=query) or_lookup = filter_by_pk(or_lookup, query) try: if query[0] == "N": val = int(query[1:]) or_lookup = Q(pk=val) # If string is N###### then do a simple PK filter except: # noqa pass qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups return qs def find_earliest_event_time(event, datetime_list): # If there is no start time defined, pretend it's midnight startTimeFaked = False if event.has_start_time: startDateTime = datetime.datetime.combine(event.start_date, event.start_time) else: startDateTime = datetime.datetime.combine(event.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 event.start_date return earliest class BaseEvent(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) description = models.TextField(blank=True, default='') status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL) # 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) purchase_order = models.CharField(max_length=255, blank=True, default='', verbose_name='PO') class Meta: abstract = True @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 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 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 def clean(self): errdict = {} if self.end_date and self.start_date > self.end_date: errdict['end_date'] = ['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: errdict['end_time'] = ['Unless you\'ve invented time travel, the event can\'t finish before it has started.'] return errdict @reversion.register(follow=['items']) class Event(BaseEvent): mic = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_mic', blank=True, null=True, verbose_name="MIC", on_delete=models.CASCADE) venue = models.ForeignKey('Venue', blank=True, null=True, on_delete=models.CASCADE) notes = models.TextField(blank=True, default='') 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) access_at = models.DateTimeField(blank=True, null=True) meet_at = models.DateTimeField(blank=True, null=True) # Dry-hire only checked_in_by = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='event_checked_in', blank=True, null=True, on_delete=models.CASCADE) # Monies collector = models.CharField(max_length=255, blank=True, default='', 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(blank=True, default='') @property def display_id(self): if self.pk: if self.is_rig: return f"N{self.pk:05d}" return self.pk return "????" # Calculated values """ EX Vat """ @property def sum_total(self): total = self.items.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): # No VAT is owed on internal transfers if self.internal: return 0 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 hs_done(self): return self.riskassessment is not None and len(self.checklists.all()) > 0 @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) earliest = find_earliest_event_time(self, datetime_list) return earliest @property def internal(self): return bool(self.organisation and self.organisation.union_account) @property def authorised(self): if self.internal: return self.authorisation.amount == self.total else: return bool(self.purchase_order) objects = EventManager() def get_absolute_url(self): return reverse('event_detail', kwargs={'pk': self.pk}) def __str__(self): return f"{self.display_id}: {self.name}" def clean(self): errdict = super.clean() if self.access_at is not None: if self.access_at.date() > self.start_date: errdict['access_at'] = ['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: errdict['access_at'] = ['Regardless of what some clients might think, access time cannot be after the event has started.'] if errdict != {}: # If there was an error when validation raise ValidationError(errdict) def save(self, *args, **kwargs): """Call :meth:`full_clean` before saving.""" self.full_clean() super(Event, self).save(*args, **kwargs) @reversion.register class EventItem(models.Model, RevisionMixin): event = models.ForeignKey('Event', related_name='items', blank=True, on_delete=models.CASCADE) name = models.CharField(max_length=255) description = models.TextField(blank=True, default='') quantity = models.IntegerField() cost = models.DecimalField(max_digits=10, decimal_places=2) order = models.IntegerField() reversion_hide = True @property def total_cost(self): return self.cost * self.quantity class Meta: ordering = ['order'] def __str__(self): return f"{self.event_id}.{self.order}: {self.event.name} | {self.name}" @property def activity_feed_string(self): return f"item {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, default='', verbose_name="University ID") account_code = models.CharField(max_length=50, default='', blank=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('event_detail', kwargs={'pk': self.event_id}) @property def activity_feed_string(self): return f"{self.event.display_id} (requested by {self.sent_by.initials})" @reversion.register class Subhire(BaseEvent): insurance_value = models.DecimalField(max_digits=10, decimal_places=2) # TODO Validate if this is over notifiable threshold events = models.ManyToManyField(Event) @property def display_id(self): return f"S{self.pk:05d}" class InvoiceManager(models.Manager): def outstanding_invoices(self): # Manual query is the only way I have found to do this efficiently. Not ideal but needs must sql = "SELECT * FROM " \ "(SELECT " \ "(SELECT COUNT(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payment_count\", " \ "(SELECT SUM(ei.cost * ei.quantity) FROM \"RIGS_eventitem\" AS ei WHERE ei.event_id=\"RIGS_invoice\".event_id) AS \"cost\", " \ "(SELECT SUM(p.amount) FROM \"RIGS_payment\" AS p WHERE p.invoice_id=\"RIGS_invoice\".id) AS \"payments\", " \ "\"RIGS_invoice\".\"id\", \"RIGS_invoice\".\"event_id\", \"RIGS_invoice\".\"invoice_date\", \"RIGS_invoice\".\"void\" FROM \"RIGS_invoice\") " \ "AS sub " \ "WHERE (((cost > 0.0) AND (payment_count=0)) OR (cost - payments) <> 0.0) AND void = '0'" \ "ORDER BY invoice_date" query = self.raw(sql) return query def search(self, query=None): qs = self.get_queryset() if query is not None: or_lookup = Q(event__name__icontains=query) or_lookup = filter_by_pk(or_lookup, query) # try and parse an int try: val = int(query) or_lookup = or_lookup | Q(event__pk=val) except: # noqa # not an integer pass try: if query[0] == "N": val = int(query[1:]) or_lookup = Q(event__pk=val) # If string is Nxxxxx then filter by event number elif query[0] == "#": val = int(query[1:]) or_lookup = Q(pk=val) # If string is #xxxxx then filter by invoice number except: # noqa pass qs = qs.filter(or_lookup).distinct() # distinct() is often necessary with Q lookups return qs @reversion.register(follow=['payment_set']) class Invoice(models.Model, RevisionMixin): event = models.OneToOneField('Event', on_delete=models.CASCADE) invoice_date = models.DateField(auto_now_add=True) void = models.BooleanField(default=False) reversion_perm = 'RIGS.view_invoice' objects = InvoiceManager() @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 get_absolute_url(self): return reverse('invoice_detail', kwargs={'pk': self.pk}) @property def activity_feed_string(self): return f"{self.display_id} for Event {self.event.display_id}" def __str__(self): return f"{self.display_id}: {self.event} (£{self.balance:.2f})" @property def display_id(self): return f"#{self.pk:05d}" class Meta: ordering = ['-invoice_date'] @reversion.register class Payment(models.Model, RevisionMixin): 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, default='', blank=True) reversion_hide = True def __str__(self): return f"{self.get_method_display()}: {self.amount}" @property def activity_feed_string(self): return f"payment of £{self.amount}" def validate_url(value): if not value: return # Required error is done the field obj = urlparse(value) if obj.hostname not in ('nottinghamtec.sharepoint.com'): raise ValidationError('URL must point to a location on the TEC Sharepoint') @reversion.register class RiskAssessment(models.Model, RevisionMixin): SMALL = (0, 'Small') MEDIUM = (1, 'Medium') LARGE = (2, 'Large') SIZES = (SMALL, MEDIUM, LARGE) 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?
i.e. Freelancers/Crewing Companies") other_companies = models.BooleanField(help_text="Are TEC working with any other companies on site?
e.g. TEC is providing the lighting while another company does sound") 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, default='', 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? (if yes to the above question, this person must be a Power Technician or Power Supervisor)") outside = models.BooleanField(help_text="Is the event outdoors?") 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, default='', 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_plan = models.URLField(blank=True, default='', help_text="Upload your power plan to the Sharepoint and submit a link", validators=[validate_url]) # 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, default='', 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 this venue new to you (the MIC) or new to TEC?") safe_loading = models.BooleanField(help_text="Are there any issues preventing a safe load in or out? (e.g. sufficient lighting, flat, not in a crowded area etc.)") safe_storage = models.BooleanField(help_text="Are there any problems with safe and secure equipment storage?") 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?") suspended_structures = models.BooleanField(help_text="Are any structures (excluding projector screens and IWBs) being suspended from TEC's structures?") persons_responsible_structures = models.TextField(blank=True, default='', help_text="Who are the persons on site responsible for their use?") rigging_plan = models.URLField(blank=True, default='', help_text="Upload your rigging plan to the Sharepoint and submit a link", validators=[validate_url]) # 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) supervisor_consulted = models.BooleanField(null=True) expected_values = { 'nonstandard_equipment': False, 'nonstandard_use': False, 'contractors': False, 'other_companies': False, 'crew_fatigue': False, # 'big_power': False Doesn't require checking with a super either way 'generators': False, 'other_companies_power': False, 'nonstandard_equipment_power': False, 'multiple_electrical_environments': False, 'noise_monitoring': False, 'known_venue': False, 'safe_loading': False, 'safe_storage': False, 'area_outside_of_control': False, 'barrier_required': False, 'nonstandard_emergency_procedure': False, 'special_structures': False, 'suspended_structures': False, } inverted_fields = {key: value for (key, value) in expected_values.items() if not value}.keys() def clean(self): # Check for idiots if not self.outside and self.generators: raise forms.ValidationError("Engage brain, please. No generators indoors!(!)") class Meta: ordering = ['event'] permissions = [ ('review_riskassessment', 'Can review Risk Assessments') ] @cached_property def fieldz(self): return [n.name for n in list(self._meta.get_fields()) if n.name != 'reviewed_at' and n.name != 'reviewed_by' and not n.is_relation and not n.auto_created] @property def event_size(self): # Confirm event size. Check all except generators, since generators entails outside if self.outside or self.other_companies_power or self.nonstandard_equipment_power or self.multiple_electrical_environments: return self.LARGE[0] elif self.big_power: return self.MEDIUM[0] else: return self.SMALL[0] def get_event_size_display(self): return self.SIZES[self.event_size][1] + " Event" @property def activity_feed_string(self): return str(self.event) @property def name(self): return str(self) def get_absolute_url(self): return reverse('ra_detail', kwargs={'pk': self.pk}) def __str__(self): return f"{self.pk} | {self.event}" @reversion.register(follow=['vehicles', 'crew']) class EventChecklist(models.Model, RevisionMixin): event = models.ForeignKey('Event', related_name='checklists', on_delete=models.CASCADE) # General power_mic = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, related_name='checklists', verbose_name="Power MIC", on_delete=models.CASCADE, help_text="Who is the Power MIC?") venue = models.ForeignKey('Venue', on_delete=models.CASCADE) date = models.DateField() # Safety Checks safe_parking = models.BooleanField(blank=True, null=True, help_text="Vehicles parked safely?
(does not obstruct venue access)") safe_packing = models.BooleanField(blank=True, null=True, help_text="Equipment packed away safely?
(including flightcases)") exits = models.BooleanField(blank=True, null=True, help_text="Emergency exits clear?") trip_hazard = models.BooleanField(blank=True, null=True, help_text="Appropriate barriers around kit and cabling secured?") warning_signs = models.BooleanField(blank=True, help_text="Warning signs in place?
(strobe, smoke, power etc.)") ear_plugs = models.BooleanField(blank=True, null=True, help_text="Ear plugs issued to crew where needed?") hs_location = models.CharField(blank=True, default='', max_length=255, help_text="Location of Safety Bag/Box") extinguishers_location = models.CharField(blank=True, default='', max_length=255, help_text="Location of fire extinguishers") # Small Electrical Checks rcds = models.BooleanField(blank=True, null=True, help_text="RCDs installed where needed and tested?") supply_test = models.BooleanField(blank=True, null=True, help_text="Electrical supplies tested?
(using socket tester)") # Shared electrical checks earthing = models.BooleanField(blank=True, null=True, help_text="Equipment appropriately earthed?
(truss, stage, generators etc)") pat = models.BooleanField(blank=True, null=True, help_text="All equipment in PAT period?") # Medium Electrical Checks source_rcd = models.BooleanField(blank=True, null=True, help_text="Source RCD protected?
(if cable is more than 3m long) ") labelling = models.BooleanField(blank=True, null=True, help_text="Appropriate and clear labelling on distribution and cabling?") # First Distro fd_voltage_l1 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L1-N", help_text="L1 - N") fd_voltage_l2 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L2-N", help_text="L2 - N") fd_voltage_l3 = models.IntegerField(blank=True, null=True, verbose_name="First Distro Voltage L3-N", help_text="L3 - N") fd_phase_rotation = models.BooleanField(blank=True, null=True, verbose_name="Phase Rotation", help_text="Phase Rotation
(if required)") fd_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (ZS)") fd_pssc = models.IntegerField(blank=True, null=True, verbose_name="PSCC", help_text="Prospective Short Circuit Current") # Worst case points w1_description = models.CharField(blank=True, default='', max_length=255, help_text="Description") w1_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?") w1_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage") w1_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (ZS)") w2_description = models.CharField(blank=True, default='', max_length=255, help_text="Description") w2_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?") w2_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage") w2_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (ZS)") w3_description = models.CharField(blank=True, default='', max_length=255, help_text="Description") w3_polarity = models.BooleanField(blank=True, null=True, help_text="Polarity Checked?") w3_voltage = models.IntegerField(blank=True, null=True, help_text="Voltage") w3_earth_fault = models.DecimalField(blank=True, null=True, max_digits=5, decimal_places=2, verbose_name="Earth Fault Loop Impedance", help_text="Earth Fault Loop Impedance (ZS)") all_rcds_tested = models.BooleanField(blank=True, null=True, help_text="All circuit RCDs tested?
(using test button)") public_sockets_tested = models.BooleanField(blank=True, null=True, help_text="Public/Performer accessible circuits tested?
(using socket tester)") 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) inverted_fields = [] class Meta: ordering = ['event'] permissions = [ ('review_eventchecklist', 'Can review Event Checklists') ] @cached_property def fieldz(self): return [n.name for n in list(self._meta.get_fields()) if n.name != 'reviewed_at' and n.name != 'reviewed_by' and not n.is_relation and not n.auto_created] @property def activity_feed_string(self): return str(self.event) def get_absolute_url(self): return reverse('ec_detail', kwargs={'pk': self.pk}) def __str__(self): return f"{self.pk} - {self.event}" @reversion.register class EventChecklistVehicle(models.Model, RevisionMixin): checklist = models.ForeignKey('EventChecklist', related_name='vehicles', blank=True, on_delete=models.CASCADE) vehicle = models.CharField(max_length=255) driver = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='vehicles', on_delete=models.CASCADE) reversion_hide = True def __str__(self): return f"{self.vehicle} driven by {self.driver}" @reversion.register class EventChecklistCrew(models.Model, RevisionMixin): checklist = models.ForeignKey('EventChecklist', related_name='crew', blank=True, on_delete=models.CASCADE) crewmember = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='crewed', on_delete=models.CASCADE) role = models.CharField(max_length=255) start = models.DateTimeField() end = models.DateTimeField() reversion_hide = True def clean(self): if self.start > self.end: raise ValidationError('Unless you\'ve invented time travel, crew can\'t finish before they have started.') def __str__(self): return f"{self.crewmember} ({self.role})"