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
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):
# last_login = None ensures we only pick up genuinely new users, not those that have been deactivated for inactivity
return Profile.objects.filter(
is_approved=False, last_login=None, date_joined_date=timezone.now().date()
).count()
def __str__(self):
return self.name
def current_event(self):
q = EventCheckIn.objects.filter(person=self, end_time=None)
return q.latest("time") if q.exists() else None
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 validate_forum_url(value):
if not value:
return # Required error is done the field
obj = urlparse(value)
if obj.hostname not in ("forum.nottinghamtec.co.uk"):
raise ValidationError("URL must point to a location on the TEC Forum")
@reversion.register(follow=["items"])
class Event(models.Model, RevisionMixin):
# Done to make it much nicer on the database
PROVISIONAL = 0
CONFIRMED = 1
BOOKED = 2
CANCELLED = 3
EVENT_STATUS_CHOICES = (
(PROVISIONAL, "Provisional"),
(CONFIRMED, "Confirmed"),
(BOOKED, "Booked"),
(CANCELLED, "Cancelled"),
)
name = models.CharField(max_length=255)
person = models.ForeignKey("Person", null=True, blank=True, on_delete=models.CASCADE)
organisation = models.ForeignKey("Organisation", blank=True, null=True, on_delete=models.CASCADE)
venue = models.ForeignKey("Venue", blank=True, null=True, on_delete=models.CASCADE)
description = models.TextField(blank=True, default="")
notes = models.TextField(blank=True, default="")
status = models.IntegerField(choices=EVENT_STATUS_CHOICES, default=PROVISIONAL)
dry_hire = models.BooleanField(default=False)
is_rig = models.BooleanField(default=True)
based_on = models.ForeignKey(
"Event", on_delete=models.SET_NULL, related_name="future_events", blank=True, null=True
)
# Timing
start_date = models.DateField()
start_time = models.TimeField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True)
end_time = models.TimeField(blank=True, null=True)
access_at = models.DateTimeField(blank=True, null=True)
meet_at = models.DateTimeField(blank=True, null=True)
# Venue requirements
parking_and_access = models.BooleanField(default=False)
# Crew management
checked_in_by = models.ForeignKey(
settings.AUTH_USER_MODEL, related_name="event_checked_in", blank=True, null=True, on_delete=models.CASCADE
)
mic = models.ForeignKey(
settings.AUTH_USER_MODEL,
related_name="event_mic",
blank=True,
null=True,
verbose_name="MIC",
on_delete=models.CASCADE,
)
# Monies
purchase_order = models.CharField(max_length=255, blank=True, default="", verbose_name="PO")
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="")
forum_url = models.URLField(default="", blank=True, validators=[validate_forum_url])
@property
def display_id(self):
if self.pk:
if self.is_rig:
return f"N{self.pk:05d}"
return self.pk
return "????"
@property
def needs_mic(self):
return self.is_rig and not self.dry_hire
# 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 cancelled(self):
return self.status == self.CANCELLED
@property
def confirmed(self):
return self.status == self.BOOKED or self.status == self.CONFIRMED
@property
def hs_done(self):
return self.riskassessment is not None and self.has_checklist and self.has_power
@property
def has_checklist(self):
return self.checklists.exists()
@property
def has_power(self):
return self.power_tests.exists()
@property
def has_start_time(self):
return self.start_time is not None
@property
def has_end_time(self):
return self.end_time is not None
@property
def earliest_time(self):
"""Finds the earliest time defined in the event - this function could return either a tzaware datetime, or a naiive date object"""
# Put all the datetimes in a list
datetime_list = []
if self.access_at:
datetime_list.append(self.access_at)
if self.meet_at:
datetime_list.append(self.meet_at)
# If there is no start time defined, pretend it's midnight
startTimeFaked = False
if self.has_start_time:
startDateTime = datetime.datetime.combine(self.start_date, self.start_time)
else:
startDateTime = datetime.datetime.combine(self.start_date, datetime.time(00, 00))
startTimeFaked = True
# timezoneIssues - apply the default timezone to the naiive datetime
tz = pytz.timezone(settings.TIME_ZONE)
startDateTime = tz.localize(startDateTime)
datetime_list.append(startDateTime) # then add it to the list
earliest = min(datetime_list).astimezone(tz) # find the earliest datetime in the list
# if we faked it & it's the earliest, better own up
if startTimeFaked and earliest == startDateTime:
return self.start_date
return earliest
@property
def latest_time(self):
"""Returns the end of the event - this function could return either a tzaware datetime, or a naiive date object"""
tz = pytz.timezone(settings.TIME_ZONE)
endDate = self.end_date
if endDate is None:
endDate = self.start_date
if self.has_end_time:
endDateTime = datetime.datetime.combine(endDate, self.end_time)
tz = pytz.timezone(settings.TIME_ZONE)
endDateTime = tz.localize(endDateTime)
return endDateTime
else:
return endDate
@property
def internal(self):
return 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)
@property
def can_check_in(self):
earliest = self.earliest_time
if isinstance(self.earliest_time, datetime.date):
earliest = datetime.datetime.combine(self.earliest_time, datetime.time(00, 00))
tz = pytz.timezone(settings.TIME_ZONE)
earliest = tz.localize(earliest)
return not self.dry_hire and not self.status == Event.CANCELLED and earliest <= timezone.now()
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 = {}
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."]
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 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"
ADJUSTMENT = "T"
METHODS = (
(CASH, "Cash"),
(INTERNAL, "Internal"),
(EXTERNAL, "External"),
(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")
class ReviewableModel(models.Model):
reviewed_at = models.DateTimeField(null=True, blank=True)
reviewed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, blank=True, null=True, verbose_name="Reviewer", on_delete=models.CASCADE
)
class Meta:
abstract = True
@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
]
@reversion.register
class RiskAssessment(ReviewableModel, 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],
)
# Venue Access
parking_and_access = models.BooleanField(
help_text="Are there additional requirements for parking and access to the venue? (i.e. campus parking permits, event access wristbands)"
)
# Blimey that was a lot of options
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,
"parking_and_access": 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")]
@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"
def __str__(self):
return f"{self.pk} | {self.event}"
def get_absolute_url(self):
return reverse("ra_detail", kwargs={"pk": self.pk})
@property
def activity_feed_string(self):
return str(self.event)
@property
def name(self):
return str(self)
@reversion.register
class EventChecklist(ReviewableModel, RevisionMixin):
event = models.ForeignKey("Event", related_name="checklists", on_delete=models.CASCADE)
# General
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"
)
inverted_fields = []
class Meta:
ordering = ["event"]
permissions = [("review_eventchecklist", "Can review Event Checklists")]
def __str__(self):
return f"{self.pk} - {self.event}"
@property
def activity_feed_string(self):
return str(self.event)
def get_absolute_url(self):
return reverse("ec_detail", kwargs={"pk": self.pk})
@reversion.register
class PowerTestRecord(ReviewableModel, RevisionMixin):
earth_fault_text = "Earth Fault Loop Impedance (ZS) / Ω"
pssc_text = "Prospective Short Circuit Current / A"
event = models.ForeignKey("Event", related_name="power_tests", on_delete=models.CASCADE)
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)
notes = models.TextField(blank=True, default="")
# 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=6,
decimal_places=2,
verbose_name="Earth Fault Loop Impedance",
help_text=earth_fault_text,
)
fd_pssc = models.IntegerField(blank=True, null=True, verbose_name="PSCC", help_text=pssc_text)
# 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 / V")
w1_earth_fault = models.DecimalField(
blank=True,
null=True,
max_digits=6,
decimal_places=2,
verbose_name="Earth Fault Loop Impedance",
help_text=earth_fault_text,
)
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 / V")
w2_earth_fault = models.DecimalField(
blank=True,
null=True,
max_digits=6,
decimal_places=2,
verbose_name="Earth Fault Loop Impedance",
help_text=earth_fault_text,
)
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 / V")
w3_earth_fault = models.DecimalField(
blank=True,
null=True,
max_digits=6,
decimal_places=2,
verbose_name="Earth Fault Loop Impedance",
help_text=earth_fault_text,
)
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)",
)
class Meta:
ordering = ["event"]
permissions = [("review_power", "Can review Power Test Records")]
def __str__(self):
return f"{self.pk} - {self.event}"
def get_absolute_url(self):
return reverse("pt_detail", kwargs={"pk": self.pk})
@property
def activity_feed_string(self):
return str(self.event)
@property
def name(self):
return f"Power Test Record - {self.event}"
class EventCheckIn(models.Model):
event = models.ForeignKey("Event", related_name="crew", on_delete=models.CASCADE)
person = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="checkins", on_delete=models.CASCADE)
time = models.DateTimeField()
role = models.CharField(max_length=50, blank=True)
vehicle = models.CharField(max_length=100, blank=True)
end_time = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"{self.person} on {self.event}"
def clean(self):
sass = " Please invent time travel and retry."
if self.time > timezone.now():
raise ValidationError("May not check in in the future." + sass)
if self.end_time and self.end_time < self.time:
raise ValidationError("May not check out before you've checked in." + sass)
def get_absolute_url(self):
return reverse("event_detail", kwargs={"pk": self.event_id})
def active(self):
return self.end_time is None