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

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

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

98

David Haynes's avatar
David Haynes committed
99 100 101 102 103 104
# 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
105 106


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

David Haynes's avatar
David Haynes committed
114

Ben Waters's avatar
Ben Waters committed
115
class Facility(TimeStampedModel):
David Haynes's avatar
David Haynes committed
116
    """
David Haynes's avatar
David Haynes committed
117 118 119
    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
120
    """
121

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

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

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

144
    # A link to the logo image for this Facility
145 146 147 148 149 150
    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.",
    )
151

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

    # The schedule that is defaulted to if no special schedule is in effect
156 157 158
    main_schedule = models.ForeignKey(
        "Schedule", related_name="facility_main", on_delete=models.CASCADE
    )
David Haynes's avatar
David Haynes committed
159
    # A schedule that has a specific start and end date
160 161 162 163 164 165
    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
166

167 168
    # URL, if it exists, to the Tapingo page that is associated with this
    # facility
169 170 171 172 173 174 175 176 177 178
    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",
            )
        ],
    )
179

180 181
    # Phone number for a location if provided. Accept both ###-###-#### or
    # without dashes
182 183 184 185 186 187 188 189 190 191 192
    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",
            )
        ],
    )
193

David Haynes's avatar
David Haynes committed
194
    # A comma seperate list of words that neatly and aptly describe the product
195
    # that this facility produces. (ex. for Taco Bell: mexican, taco, cheap)
David Haynes's avatar
David Haynes committed
196
    # These words are not shown to the use but are rather used in search.
197 198 199 200
    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
201 202 203

    # Labels to describe the Facility that are displayed to the user and can be
    # informative. "Takes Mason Money"
204 205 206 207 208 209
    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
210 211 212 213 214

    # Tag a Facility to be shown on the ShopMason or Sodoxo (or both)
    # What's Open sites.
    FACILITY_CLASSES = (
        ("shopmason", "shopMason Facility"),
215 216 217 218 219 220 221
        ("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
222
    )
David Haynes's avatar
David Haynes committed
223

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

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

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

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

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

287

Ben Waters's avatar
Ben Waters committed
288
class Schedule(TimeStampedModel):
289
    """
David Haynes's avatar
David Haynes committed
290 291
    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.
292
    """
293

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

    # The start date of the schedule
298
    # (inclusive)
299 300 301 302 303 304
    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
305 306
    # The end date of the schedule
    # (inclusive)
307 308 309 310 311 312
    valid_end = models.DateTimeField(
        "End Date",
        null=True,
        blank=True,
        help_text="Last date & time that this schedule is in effect",
    )
313

David Haynes's avatar
David Haynes committed
314
    # Boolean for if this schedule is 24 hours
315 316 317 318 319 320
    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.",
    )
321

322
    # Boolean for if this schedule should never be removed.
323 324 325 326 327 328
    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.",
    )
329
    # Boolean for if this schedule should become the main schedule at the point
Michael T Bailey's avatar
Michael T Bailey committed
330
    # it goes live
331 332 333 334 335 336
    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.",
    )
337

David Haynes's avatar
David Haynes committed
338
    def is_open_now(self):
339 340 341
        """
        Return true if this schedule is open right now.
        """
David Haynes's avatar
David Haynes committed
342 343 344 345 346 347 348
        # 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
349
                # If the current time we are looking at is open, then the schedule
David Haynes's avatar
David Haynes committed
350 351 352 353 354 355
                # 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
356

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

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


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

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

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

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

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

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

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

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

460
    def __str__(self):
David Haynes's avatar
David Haynes committed
461 462 463
        """
        String representation of a OpenTime object.
        """
464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480
        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
481 482 483 484 485 486 487 488 489

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.
    """
490

David Haynes's avatar
David Haynes committed
491
    # Define string constants to represent urgency tag levels
492 493 494 495
    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
496

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

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

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

    # 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
521 522 523 524 525 526 527 528
    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
529 530 531 532
    def __str__(self):
        """
        String representation of an Alert object.
        """
533 534
        return "{0} \n {1} \n {3}".format(self.subject, self.body, self.url)
        # Returns the subject, body, and url fields