From 773f55ac846b6eadaf2d42615e37ea6cbdf48af0 Mon Sep 17 00:00:00 2001 From: Arona Jones Date: Mon, 19 Dec 2022 16:26:25 +0000 Subject: [PATCH] Break up RIGS models into seperate files --- RIGS/models.py | 1010 ----------------------------- RIGS/models/__init__.py | 4 + RIGS/models/events.py | 440 +++++++++++++ RIGS/models/finance.py | 170 +++++ RIGS/models/hs.py | 243 +++++++ RIGS/models/models.py | 173 +++++ RIGS/models/utils.py | 9 + RIGS/templates/subhire_table.html | 0 8 files changed, 1039 insertions(+), 1010 deletions(-) delete mode 100644 RIGS/models.py create mode 100644 RIGS/models/__init__.py create mode 100644 RIGS/models/events.py create mode 100644 RIGS/models/finance.py create mode 100644 RIGS/models/hs.py create mode 100644 RIGS/models/models.py create mode 100644 RIGS/models/utils.py create mode 100644 RIGS/templates/subhire_table.html diff --git a/RIGS/models.py b/RIGS/models.py deleted file mode 100644 index 54bec957..00000000 --- a/RIGS/models.py +++ /dev/null @@ -1,1010 +0,0 @@ -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 -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 versioning.versioning import RevisionMixin - -from .validators import validate_url - - -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, verbose_name="Approval Status", help_text="Designates whether a staff member has approved this user.") - # 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 active_dry_hires(self): - return self.filter(dry_hire=True, start_date__gte=timezone.now(), is_rig=True) - - def rig_count(self): - event_count = self.exclude(status=BaseEvent.CANCELLED).filter( - (models.Q(start_date__gte=timezone.now(), end_date__isnull=True, dry_hire=False, - is_rig=True)) | # Starts after with no end - (models.Q(end_date__gte=timezone.now(), dry_hire=False, is_rig=True)) | # Ends after - (models.Q(dry_hire=True, start_date__gte=timezone.now(), is_rig=True)) # 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 - - @property - def length(self): - start = self.earliest_time - if isinstance(self.earliest_time, datetime.datetime): - start = self.earliest_time.date() - end = self.latest_time - if isinstance(self.latest_time, datetime.datetime): - end = self.latest_time.date() - return (end - start).days - - 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 - - def __str__(self): - return f"{self.display_id}: {self.name}" - -@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 and hasattr(self, 'authorisation'): - return self.authorisation.amount == self.total - else: - return bool(self.purchase_order) - - @property - def color(self): - if self.cancelled: - return "secondary" - elif not self.is_rig: - return "info" - elif not self.mic: - return "danger" - elif self.confirmed and self.authorised: - if self.dry_hire or self.riskassessment: - return "success" - else: - return "warning" - else: - return "warning" - - objects = EventManager() - - def get_absolute_url(self): - return reverse('event_detail', kwargs={'pk': self.pk}) - - def get_edit_url(self): - return reverse('event_update', kwargs={'pk': self.pk}) - - 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})" - - -class SubhireManager(models.Manager): - def current_events(self): - events = self.exclude(status=BaseEvent.CANCELLED).filter( - (models.Q(start_date__gte=timezone.now(), end_date__isnull=True)) | # Starts after with no end - (models.Q(end_date__gte=timezone.now().date())) # Ends after - ).order_by('start_date', 'end_date', 'start_time', 'end_time').select_related('person', 'organisation') - - return events - - def event_count(self): - event_count = self.exclude(status=BaseEvent.CANCELLED).filter( - (models.Q(start_date__gte=timezone.now(), end_date__isnull=True)) | # Starts after with no end - (models.Q(end_date__gte=timezone.now())) - ).count() - return event_count - - -@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) - quote = models.URLField(default='', validators=[validate_url]) - - - objects = SubhireManager() - - @property - def display_id(self): - return f"S{self.pk:05d}" - - @property - def color(self): - return "purple" - - def get_edit_url(self): - return reverse('subhire_update', kwargs={'pk': self.pk}) - - def get_absolute_url(self): - return reverse('subhire_detail', kwargs={'pk': self.pk}) - - @property - def earliest_time(self): - return find_earliest_event_time(self, []) - - class Meta: - permissions = [ - ('subhire_finance', 'Can see financial data for subhire - insurance values') - ] - - -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}" - - -@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})" diff --git a/RIGS/models/__init__.py b/RIGS/models/__init__.py new file mode 100644 index 00000000..6fe48b9b --- /dev/null +++ b/RIGS/models/__init__.py @@ -0,0 +1,4 @@ +from .models import * +from .finance import * +from .hs import * +from .events import * diff --git a/RIGS/models/events.py b/RIGS/models/events.py new file mode 100644 index 00000000..d75f1e7c --- /dev/null +++ b/RIGS/models/events.py @@ -0,0 +1,440 @@ +import datetime +from decimal import Decimal + +import pytz +from django.db.models import Q +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.functional import cached_property +from reversion import revisions as reversion +from versioning.versioning import RevisionMixin + +from RIGS.validators import validate_url +from .utils import filter_by_pk +from .finance import VatRate + + +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 active_dry_hires(self): + return self.filter(dry_hire=True, start_date__gte=timezone.now(), is_rig=True) + + def rig_count(self): + event_count = self.exclude(status=BaseEvent.CANCELLED).filter( + (models.Q(start_date__gte=timezone.now(), end_date__isnull=True, dry_hire=False, + is_rig=True)) | # Starts after with no end + (models.Q(end_date__gte=timezone.now(), dry_hire=False, is_rig=True)) | # Ends after + (models.Q(dry_hire=True, start_date__gte=timezone.now(), is_rig=True)) # 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 + + @property + def length(self): + start = self.earliest_time + if isinstance(self.earliest_time, datetime.datetime): + start = self.earliest_time.date() + end = self.latest_time + if isinstance(self.latest_time, datetime.datetime): + end = self.latest_time.date() + return (end - start).days + + 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 + + def __str__(self): + return f"{self.display_id}: {self.name}" + +@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 and hasattr(self, 'authorisation'): + return self.authorisation.amount == self.total + else: + return bool(self.purchase_order) + + @property + def color(self): + if self.cancelled: + return "secondary" + elif not self.is_rig: + return "info" + elif not self.mic: + return "danger" + elif self.confirmed and self.authorised: + if self.dry_hire or self.riskassessment: + return "success" + else: + return "warning" + else: + return "warning" + + objects = EventManager() + + def get_absolute_url(self): + return reverse('event_detail', kwargs={'pk': self.pk}) + + def get_edit_url(self): + return reverse('event_update', kwargs={'pk': self.pk}) + + 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})" + + +class SubhireManager(models.Manager): + def current_events(self): + events = self.exclude(status=BaseEvent.CANCELLED).filter( + (models.Q(start_date__gte=timezone.now(), end_date__isnull=True)) | # Starts after with no end + (models.Q(end_date__gte=timezone.now().date())) # Ends after + ).order_by('start_date', 'end_date', 'start_time', 'end_time').select_related('person', 'organisation') + + return events + + def event_count(self): + event_count = self.exclude(status=BaseEvent.CANCELLED).filter( + (models.Q(start_date__gte=timezone.now(), end_date__isnull=True)) | # Starts after with no end + (models.Q(end_date__gte=timezone.now())) + ).count() + return event_count + + +@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) + quote = models.URLField(default='', validators=[validate_url]) + + + objects = SubhireManager() + + @property + def display_id(self): + return f"S{self.pk:05d}" + + @property + def color(self): + return "purple" + + def get_edit_url(self): + return reverse('subhire_update', kwargs={'pk': self.pk}) + + def get_absolute_url(self): + return reverse('subhire_detail', kwargs={'pk': self.pk}) + + @property + def earliest_time(self): + return find_earliest_event_time(self, []) + + class Meta: + permissions = [ + ('subhire_finance', 'Can see financial data for subhire - insurance values') + ] diff --git a/RIGS/models/finance.py b/RIGS/models/finance.py new file mode 100644 index 00000000..a01ad209 --- /dev/null +++ b/RIGS/models/finance.py @@ -0,0 +1,170 @@ +from decimal import Decimal + +from django.db.models import Q +from django.db import models +from django.urls import reverse +from django.utils import timezone +from reversion import revisions as reversion +from versioning.versioning import RevisionMixin +from .utils import filter_by_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 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}" \ No newline at end of file diff --git a/RIGS/models/hs.py b/RIGS/models/hs.py new file mode 100644 index 00000000..922a3556 --- /dev/null +++ b/RIGS/models/hs.py @@ -0,0 +1,243 @@ +from django import forms +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils.functional import cached_property +from reversion import revisions as reversion +from versioning.versioning import RevisionMixin + +from RIGS.validators import validate_url + + +@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})" diff --git a/RIGS/models/models.py b/RIGS/models/models.py new file mode 100644 index 00000000..740a1719 --- /dev/null +++ b/RIGS/models/models.py @@ -0,0 +1,173 @@ +import hashlib +import random +import string +from collections import Counter + +from django.db.models import Q +from django.conf import settings +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.urls import reverse +from versioning.versioning import RevisionMixin +from .events import Event +from .utils import filter_by_pk + + +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, verbose_name="Approval Status", help_text="Designates whether a staff member has approved this user.") + # 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 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}) diff --git a/RIGS/models/utils.py b/RIGS/models/utils.py new file mode 100644 index 00000000..b7b5b705 --- /dev/null +++ b/RIGS/models/utils.py @@ -0,0 +1,9 @@ +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 \ No newline at end of file diff --git a/RIGS/templates/subhire_table.html b/RIGS/templates/subhire_table.html new file mode 100644 index 00000000..e69de29b