models.py 17.5 KB
Newer Older
1 2
#!/usr/bin/env python
# -*- coding: utf-8 -*-
David Haynes's avatar
David Haynes committed
3 4 5 6 7 8 9 10
"""
api/models.py

Define the objects that will be stored in the database and later served through
the API.

https://docs.djangoproject.com/en/1.11/topics/db/models/
"""
11 12 13 14
# Python stdlib Imports
import datetime

# Django Imports
15
from django.db import models
David Haynes's avatar
David Haynes committed
16
from django.contrib.gis.db.models import PointField
17
from django.contrib.auth.models import User
18
from django.core.validators import RegexValidator
David Haynes's avatar
David Haynes committed
19
from django.utils import timezone
David Haynes's avatar
David Haynes committed
20 21

# Other Imports
Ben Waters's avatar
Ben Waters committed
22
from model_utils.models import TimeStampedModel
23
from autoslug import AutoSlugField
24
from taggit.managers import TaggableManager
David Haynes's avatar
David Haynes committed
25
from taggit.models import GenericTaggedItemBase, TagBase
Tyler Hallada's avatar
Tyler Hallada committed
26

27

Ben Waters's avatar
Ben Waters committed
28
class Category(TimeStampedModel):
29
    """
David Haynes's avatar
David Haynes committed
30 31 32 33 34 35 36
    Represents the "category" that a Facility falls under. A Category is a
    grouping of Facilities that serve a common/similar purpose.

    ex.
    - Dining
    - Gyms
    - Study areas (Libraries, The Ridge, JC, etc)
37
    """
38

David Haynes's avatar
David Haynes committed
39
    # The name of the category
40 41 42 43 44
    name = models.CharField(max_length=100)

    class Meta:
        verbose_name = "category"
        verbose_name_plural = "categories"
45
        # Sort by name in admin view.
46
        ordering = ["name"]
47

48
    def __str__(self):
David Haynes's avatar
David Haynes committed
49 50 51 52
        """
        String representation of a Category object.
        """
        return self.name
53

54

David Haynes's avatar
David Haynes committed
55 56
class Location(TimeStampedModel):
    """
57
    Represents a specific location that a Facility can be found.
David Haynes's avatar
David Haynes committed
58
    """
59

60 61
    CAMPUS_LOCATIONS = (
        # (set in model, human readable version)
62
        ("front royal", "Front Royal"),
63 64
        ("prince william", "Prince William County Science and Technology"),
        ("fairfax", "Fairfax"),
65
        ("arlington", "Arlington"),
66
    )
67 68
    # The building that the facility is located in (on campus).
    building = models.CharField(max_length=100)
69 70 71 72 73 74
    friendly_building = models.CharField(
        "Building Abbreviation",
        help_text="Example: Exploratory Hall becomes EXPL",
        blank=True,
        max_length=10,
    )
75 76
    # The physical address of the facility.
    address = models.CharField(max_length=100)
77 78 79
    campus_region = models.CharField(
        choices=CAMPUS_LOCATIONS, max_length=100, default="fairfax"
    )
80
    # Boolean for whether or not the location is "on campus" or not.
David Haynes's avatar
David Haynes committed
81
    on_campus = models.BooleanField(default=True)
David Haynes's avatar
David Haynes committed
82 83
    # A GeoJson coordinate pair that represents the physical location
    coordinate_location = PointField()
David Haynes's avatar
David Haynes committed
84

85 86 87 88 89 90 91 92
    class Meta:
        verbose_name = "location"
        verbose_name_plural = "locations"

    def __str__(self):
        """
        String representation of a Location object.
        """
93 94 95 96 97 98
        return "Found in %s at %s | On Campus: %s" % (
            self.building,
            self.address,
            self.on_campus,
        )

99

David Haynes's avatar
David Haynes committed
100 101 102 103 104 105
# Look I didn't want to do this but APPARENTLY you cannot have two
# TaggableManager()s on a model and thus you have to make a WHOLE other model
# to have this work.
# https://neutron-drive.appspot.com/blog/multiple-tags
class StupidFacilityLabelHack(TagBase):
    pass
106 107


David Haynes's avatar
David Haynes committed
108
class StupidLabelHack(GenericTaggedItemBase):
109 110 111 112 113 114
    tag = models.ForeignKey(
        StupidFacilityLabelHack,
        on_delete=models.CASCADE,
        related_name="%(app_label)s_%(class)s_items",
    )

David Haynes's avatar
David Haynes committed
115

Ben Waters's avatar
Ben Waters committed
116
class Facility(TimeStampedModel):
David Haynes's avatar
David Haynes committed
117
    """
David Haynes's avatar
David Haynes committed
118 119 120
    Represents a specific facility location. A Facility is some type of
    establishment that has a schedule of open hours and a location that serves
    a specific purpose that can be categorized.
David Haynes's avatar
David Haynes committed
121
    """
122

David Haynes's avatar
David Haynes committed
123
    # The name of the Facility
David Haynes's avatar
David Haynes committed
124
    facility_name = models.CharField(max_length=100)
David Haynes's avatar
David Haynes committed
125
    # Instead of id
126
    slug = AutoSlugField(populate_from="facility_name", unique=True)
127

David Haynes's avatar
David Haynes committed
128
    # The category that this facility can be grouped with
129 130 131
    facility_category = models.ForeignKey(
        "Category", related_name="categories", on_delete=models.CASCADE
    )
David Haynes's avatar
David Haynes committed
132
    # The location object that relates to this facility
133 134 135
    facility_location = models.ForeignKey(
        "Location", related_name="facilities", on_delete=models.CASCADE
    )
136

137 138
    # A note that can be left on a Facility to provide some additional
    # information.
139 140 141 142 143
    note = models.TextField(
        "Facility Note",
        blank=True,
        help_text="Additional information that is sent with this Facility.",
    )
144

145
    # A link to the logo image for this Facility
146 147 148 149 150 151
    logo = models.URLField(
        "Logo URL",
        blank=True,
        default="https://wopen-cdn.dhaynes.xyz/default.png",
        help_text="The absolute URL to the logo image for this Facility.",
    )
152

David Haynes's avatar
David Haynes committed
153
    # The User(s) that claim ownership over this facility
154
    owners = models.ManyToManyField(User)
David Haynes's avatar
David Haynes committed
155 156

    # The schedule that is defaulted to if no special schedule is in effect
157 158 159
    main_schedule = models.ForeignKey(
        "Schedule", related_name="facility_main", on_delete=models.CASCADE
    )
David Haynes's avatar
David Haynes committed
160
    # A schedule that has a specific start and end date
161 162 163 164 165 166
    special_schedules = models.ManyToManyField(
        "Schedule",
        related_name="facility_special",
        blank=True,
        help_text="This schedule will come into effect only for its specified duration.",
    )
David Haynes's avatar
David Haynes committed
167

168 169
    # URL, if it exists, to the Tapingo page that is associated with this
    # facility
170 171 172 173 174 175 176 177 178 179
    tapingo_url = models.URLField(
        blank=True,
        validators=[
            RegexValidator(
                regex="^https:\/\/www.tapingo.com\/",
                message="The link is not a valid tapingo link. Example: https://www.tapingo.com/order/restaurant/starbucks-gmu-johnson/",
                code="invalid_tapingo_url",
            )
        ],
    )
180

181 182
    # Phone number for a location if provided. Accept both ###-###-#### or
    # without dashes
183 184 185 186 187 188 189 190 191 192 193
    phone_number = models.CharField(
        blank=True,
        max_length=18,
        validators=[
            RegexValidator(
                regex="^\(?([0-9]{3})\)?[-.●]?([0-9]{3})[-.●]?([0-9]{4})$",
                message="Invalid phone number",
                code="invalid_phone_number",
            )
        ],
    )
194

David Haynes's avatar
David Haynes committed
195
    # A comma seperate list of words that neatly and aptly describe the product
196
    # that this facility produces. (ex. for Taco Bell: mexican, taco, cheap)
David Haynes's avatar
David Haynes committed
197
    # These words are not shown to the use but are rather used in search.
198 199 200 201
    facility_product_tags = TaggableManager(
        related_name="product_tags",
        help_text="A comma seperate list of words that neatly and aptly describe the product that this facility produces. These words are not shown to the use but are rather used in search.",
    )
David Haynes's avatar
David Haynes committed
202 203 204

    # Labels to describe the Facility that are displayed to the user and can be
    # informative. "Takes Mason Money"
205 206 207 208 209 210
    facility_labels = TaggableManager(
        "labels",
        related_name="labels",
        through=StupidLabelHack,
        help_text="Labels to describe the Facility that are displayed to the user and can be informative.",
    )
David Haynes's avatar
David Haynes committed
211 212 213 214 215

    # Tag a Facility to be shown on the ShopMason or Sodoxo (or both)
    # What's Open sites.
    FACILITY_CLASSES = (
        ("shopmason", "shopMason Facility"),
216 217 218 219 220 221 222
        ("sodoxo", "Sodoxo Facility"),
    )
    facility_classifier = models.CharField(
        choices=FACILITY_CLASSES,
        help_text="Tag this facility to be shown on the ShopMason or Sodoxo What's Open sites.",
        max_length=100,
        blank=True,
David Haynes's avatar
David Haynes committed
223
    )
David Haynes's avatar
David Haynes committed
224

David Haynes's avatar
David Haynes committed
225
    def is_open(self):
226
        """
227
        Return true if this facility is currently open.
228

David Haynes's avatar
David Haynes committed
229 230
        First checks any valid special schedules and then checks the main,
        default, schedule.
231
        """
David Haynes's avatar
David Haynes committed
232
        # Get the current date
233
        today = datetime.datetime.today().date()
David Haynes's avatar
David Haynes committed
234
        # Check special schedules first, loop through all of them
235 236 237
        for schedule in self.special_schedules.all():
            # Special schedules must have valid_start and valid_end set
            if schedule.valid_start and schedule.valid_end:
David Haynes's avatar
David Haynes committed
238
                # If a special schedule in in effect
239
                if schedule.valid_start <= today <= schedule.valid_end:
Michael T Bailey's avatar
Michael T Bailey committed
240
                    # Check if the facility is open or not based on that
David Haynes's avatar
David Haynes committed
241 242 243
                    # special schedule
                    if schedule.is_open_now():
                        # Open
244
                        return True
245
                    else:
David Haynes's avatar
David Haynes committed
246
                        # Closed
247
                        return False
David Haynes's avatar
David Haynes committed
248 249 250 251
        # If no special schedule is in effect then check if the facility is
        # open using the main_schedule
        if self.main_schedule.is_open_now():
            # Open
252
            return True
David Haynes's avatar
David Haynes committed
253 254 255 256
        else:
            # Closed
            return False

257
    def clean_schedules(self):
258 259
        """
        Loop through every special_schedule and remove entries that have
260
        expired as well as promote special schedules to main if necessary.
261 262 263
        """
        for special_schedule in self.special_schedules.all():
            # If it ends before today
264 265 266 267
            if (
                special_schedule.valid_end < timezone.now()
                and special_schedule.schedule_for_removal
            ):
268
                self.special_schedules.remove(special_schedule)
269
            elif special_schedule.promote_to_main:
270 271 272 273
                if (
                    special_schedule.valid_start < timezone.now()
                    and special_schedule.valid_end >= timezone.now()
                ):
274
                    self.main_schedule = special_schedule
275

David Haynes's avatar
David Haynes committed
276 277 278 279
    class Meta:
        verbose_name = "facility"
        verbose_name_plural = "facilities"
        # Sort by name in admin view
280
        ordering = ["facility_name"]
281

282
    def __str__(self):
David Haynes's avatar
David Haynes committed
283 284 285
        """
        String representation of a Facility object.
        """
David Haynes's avatar
David Haynes committed
286
        return self.facility_name
287

288

Ben Waters's avatar
Ben Waters committed
289
class Schedule(TimeStampedModel):
290
    """
David Haynes's avatar
David Haynes committed
291 292
    A period of time between two dates that represents the beginning and end of
    a "schedule" or rather, a collection of open times for a facility.
293
    """
294

David Haynes's avatar
David Haynes committed
295
    # The name of the schedule
Tyler Hallada's avatar
Tyler Hallada committed
296
    name = models.CharField(max_length=100)
David Haynes's avatar
David Haynes committed
297 298

    # The start date of the schedule
299
    # (inclusive)
300 301 302 303 304 305
    valid_start = models.DateTimeField(
        "Start Date",
        null=True,
        blank=True,
        help_text="Date & time that this schedule goes into effect",
    )
David Haynes's avatar
David Haynes committed
306 307
    # The end date of the schedule
    # (inclusive)
308 309 310 311 312 313
    valid_end = models.DateTimeField(
        "End Date",
        null=True,
        blank=True,
        help_text="Last date & time that this schedule is in effect",
    )
314

David Haynes's avatar
David Haynes committed
315
    # Boolean for if this schedule is 24 hours
316 317 318 319 320 321
    twenty_four_hours = models.BooleanField(
        "24 hour schedule?",
        blank=True,
        default=False,
        help_text="Toggle to True if the Facility is open 24 hours. You do not need to specify any Open Times, it will always be displayed as open.",
    )
322

323
    # Boolean for if this schedule should never be removed.
324 325 326 327 328 329
    schedule_for_removal = models.BooleanField(
        "Schedule for removal?",
        blank=False,
        default=True,
        help_text="Toggle to False if the schedule should never be removed in the backend. By default, all schedules are automatically deleted after they have expired.",
    )
330
    # Boolean for if this schedule should become the main schedule at the point
Michael T Bailey's avatar
Michael T Bailey committed
331
    # it goes live
332 333 334 335 336 337
    promote_to_main = models.BooleanField(
        "Schedule for promotion?",
        blank=False,
        default=False,
        help_text="Upon the start of the schedule, it will be promoted to become the main schedule of the Facility it is attached to rather than a special schedule.",
    )
338

David Haynes's avatar
David Haynes committed
339
    def is_open_now(self):
340 341 342
        """
        Return true if this schedule is open right now.
        """
David Haynes's avatar
David Haynes committed
343 344 345 346 347 348 349
        # If the schedule is a 24 hour one, then it's open.
        if self.twenty_four_hours:
            return True
        # Otherwise let's check if it's open.
        else:
            # Loop through all the open times that correspond to this schedule
            for open_time in OpenTime.objects.filter(schedule=self):
Michael T Bailey's avatar
Michael T Bailey committed
350
                # If the current time we are looking at is open, then the schedule
David Haynes's avatar
David Haynes committed
351 352 353 354 355 356
                # will say that the facility is open
                if open_time.is_open_now():
                    # Open
                    return True
            # Closed (all open times are not open)
            return False
357

David Haynes's avatar
David Haynes committed
358 359
    class Meta:
        # Sort by name in admin view
360
        ordering = ["name"]
David Haynes's avatar
David Haynes committed
361

362
    def __str__(self):
David Haynes's avatar
David Haynes committed
363 364 365
        """
        String representation of a Schedule object.
        """
366 367 368
        return self.name


Ben Waters's avatar
Ben Waters committed
369
class OpenTime(TimeStampedModel):
370
    """
David Haynes's avatar
David Haynes committed
371 372 373
    Represents a time period when a Facility is open.

    Monday = 0, Sunday = 6.
374
    """
375

David Haynes's avatar
David Haynes committed
376
    # Define integer constants to represent days of the week
377 378 379 380 381 382 383 384
    MONDAY = 0
    TUESDAY = 1
    WEDNESDAY = 2
    THURSDAY = 3
    FRIDAY = 4
    SATURDAY = 5
    SUNDAY = 6

David Haynes's avatar
David Haynes committed
385
    # Tuple that ties a day of the week with an integer representation
386
    DAY_CHOICES = (
387 388 389 390 391 392 393
        (MONDAY, "Monday"),
        (TUESDAY, "Tuesday"),
        (WEDNESDAY, "Wednesday"),
        (THURSDAY, "Thursday"),
        (FRIDAY, "Friday"),
        (SATURDAY, "Saturday"),
        (SUNDAY, "Sunday"),
394 395
    )

David Haynes's avatar
David Haynes committed
396
    # The schedule that this period of open time is a part of
397 398 399
    schedule = models.ForeignKey(
        "Schedule", related_name="open_times", on_delete=models.CASCADE
    )
David Haynes's avatar
David Haynes committed
400 401

    # The day that the open time begins on
402
    start_day = models.IntegerField(default=0, choices=DAY_CHOICES)
David Haynes's avatar
David Haynes committed
403
    # The day that the open time ends on
404
    end_day = models.IntegerField(default=0, choices=DAY_CHOICES)
David Haynes's avatar
David Haynes committed
405 406 407 408

    # The time of day that the open time begins at
    start_time = models.TimeField()
    # The time of day that the open time ends
409 410
    end_time = models.TimeField()

David Haynes's avatar
David Haynes committed
411
    def is_open_now(self):
412
        """
David Haynes's avatar
David Haynes committed
413
        Return true if the current time is this OpenTime's range.
414
        """
David Haynes's avatar
David Haynes committed
415
        # Get the current datetime
416
        today = datetime.datetime.today()
David Haynes's avatar
David Haynes committed
417
        # Check that the start occurs before the end
418
        if self.start_day <= self.end_day:
David Haynes's avatar
David Haynes committed
419
            # If today is the start_day
420
            if self.start_day == today.weekday():
David Haynes's avatar
David Haynes committed
421
                # If the start_time has not occurred
422
                if self.start_time > today.time():
David Haynes's avatar
David Haynes committed
423
                    # Closed
424
                    return False
David Haynes's avatar
David Haynes committed
425
            # If the start_day has not occurred
426
            elif self.start_day > today.weekday():
David Haynes's avatar
David Haynes committed
427
                # Closed
428
                return False
David Haynes's avatar
David Haynes committed
429
            # If the end_day is today
430
            if self.end_day == today.weekday():
David Haynes's avatar
David Haynes committed
431
                # If the end_time has already occurred
432
                if self.end_time < today.time():
David Haynes's avatar
David Haynes committed
433
                    # Closed
434
                    return False
David Haynes's avatar
David Haynes committed
435
            # If the end_day has already occurred
436
            elif self.end_day < today.weekday():
David Haynes's avatar
David Haynes committed
437
                # Closed
438
                return False
David Haynes's avatar
David Haynes committed
439
        # The end_day > start_day
440
        else:
David Haynes's avatar
David Haynes committed
441
            # If today is the start_day
442
            if self.start_day == today.weekday():
David Haynes's avatar
David Haynes committed
443
                # If the start_time has not occurred
444
                if self.start_time > today.time():
David Haynes's avatar
David Haynes committed
445
                    # Closed
446
                    return False
David Haynes's avatar
David Haynes committed
447
            # If the end_day is today
448
            if self.end_day == today.weekday():
David Haynes's avatar
David Haynes committed
449
                # If the end_time has already occurred
450
                if self.end_time < today.time():
David Haynes's avatar
David Haynes committed
451
                    # Closed
452
                    return False
David Haynes's avatar
David Haynes committed
453 454
            # If the current date takes place after the end_date but before
            # start_day
455
            if self.end_day < today.weekday() < self.start_day:
David Haynes's avatar
David Haynes committed
456
                # Closed
457
                return False
David Haynes's avatar
David Haynes committed
458
        # All checks passed, it's Open
459
        return True
460

461
    def __str__(self):
David Haynes's avatar
David Haynes committed
462 463 464
        """
        String representation of a OpenTime object.
        """
465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481
        weekdays = [
            "Monday",
            "Tuesday",
            "Wednesday",
            "Thursday",
            "Friday",
            "Saturday",
            "Sunday",
        ]
        return "%s %s to %s %s" % (
            weekdays[self.start_day],
            self.start_time.strftime("%H:%M:%S"),
            # to
            weekdays[self.end_day],
            self.end_time.strftime("%H:%M:%S"),
        )

David Haynes's avatar
David Haynes committed
482 483 484 485 486 487 488 489 490

class Alert(TimeStampedModel):
    """
    Some type of notification that is displayed to clients that conveys a
    message. Past examples include: random closings, modified schedules being
    in effect, election reminder, advertising for other SRCT projects.

    Alerts last for a period of time until the information is no longer dank.
    """
491

David Haynes's avatar
David Haynes committed
492
    # Define string constants to represent urgency tag levels
493 494 495 496
    INFO = "info"  # SRCT announcements
    MINOR = "minor"  # Holiday hours are in effect
    MAJOR = "major"  # The hungry patriot is closed today
    EMERGENCY = "emergency"  # Extreme weather
David Haynes's avatar
David Haynes committed
497

David Haynes's avatar
David Haynes committed
498
    # Tuple that ties a urgency tag with a string representation
David Haynes's avatar
David Haynes committed
499
    URGENCY_CHOICES = (
500 501 502 503
        (INFO, "Info"),
        (MINOR, "Minor"),
        (MAJOR, "Major"),
        (EMERGENCY, "Emergency"),
David Haynes's avatar
David Haynes committed
504 505 506
    )

    # The urgency tag for this Alert
507 508 509
    urgency_tag = models.CharField(
        max_length=10, default="Info", choices=URGENCY_CHOICES
    )
510

David Haynes's avatar
David Haynes committed
511
    # The text that is displayed that describes the Alert
512 513 514
    subject = models.CharField(max_length=130)
    body = models.TextField()
    url = models.URLField(max_length=200)
David Haynes's avatar
David Haynes committed
515 516 517 518 519 520 521

    # The date + time that the alert will be start being served
    start_datetime = models.DateTimeField()

    # The date + time that the alert will stop being served
    end_datetime = models.DateTimeField()

David Haynes's avatar
David Haynes committed
522 523 524 525 526 527 528 529
    def is_active(self):
        """
        Check if the current Alert object is active (Alert-able).
        """
        # Get the current datetime
        now = timezone.now()
        return self.start_datetime < now < self.end_datetime

David Haynes's avatar
David Haynes committed
530 531 532 533
    def __str__(self):
        """
        String representation of an Alert object.
        """
534 535
        return "{0} \n {1} \n {3}".format(self.subject, self.body, self.url)
        # Returns the subject, body, and url fields