Commit 0e93fe98 authored by David Haynes's avatar David Haynes 🙆

Merge branch '2.1-dev' into 'master'

2.1 Release

Closes #72, #64, #65, #63, #37, #61, #62, #68, #70, and #67

See merge request !38
parents 0eaa04fe 131432f5
Pipeline #1909 passed with stage
in 1 minute and 27 seconds
...@@ -26,12 +26,6 @@ before_script: ...@@ -26,12 +26,6 @@ before_script:
- python manage.py migrate - python manage.py migrate
- echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('root', 'root@srct.gmu.edu', 'root') " | python manage.py shell - echo "from django.contrib.auth import get_user_model; User = get_user_model(); User.objects.create_superuser('root', 'root@srct.gmu.edu', 'root') " | python manage.py shell
whats-open-py2.7:
image: library/python:2.7
type: test
script:
- python manage.py test
whats-open-py3.5: whats-open-py3.5:
image: library/python:3.5 image: library/python:3.5
type: test type: test
......
## Summary ## Summary
Here you should include two to three sentences explaining the thought process Here you should include two to three sentences explaining the thought process
about the current issue. Perhaps a picture? Some details that could best help someone, about the current issue. Perhaps a picture? Some details that could best help someone,
especially someone new, understand the goal of the issue and how they should best especially someone new, understand the goal of the issue and how they should best
approach the problem. approach the problem.
## Helpful Links ## Helpful Links
Here you should include a bullet point list of links to documentation, stack overflow, Here you should include a bullet point list of links to documentation, stack overflow,
whatever, that could help guide someone on what it is they are trying to do. whatever, that could help guide someone on what it is they are trying to do.
Essentially, a list of links to point them in the right direction. Essentially, a list of links to point them in the right direction.
\ No newline at end of file
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## [2.1.0] - 2017-12-29
### Added
- Django 2.0
- Facility tagging overhaul (tags, labels, and classifiers)
- Facilites can have phone numbers
- Special Schedules can be promoted to Main Schedule status on a provided datetime
- "Note" field added to Facilites
- Facilities can have associated logos
- "Friendly" building names
- Option to allow special schedules to not expire
- Docker Swarm integration
### Changed
- The map in the admin for Location now has a default (x,y) position over GMU FFX rather than the middle of the ocean
- Special Schedules can start at a specific time on a date
[2.1.0]: https://git.gmu.edu/srct/whats-open/compare/2.1...2.0
############################################################ ############################################################
# Dockerfile to build What's Open Django App # What's Open API v2
############################################################ ############################################################
# Set the base image to Ubuntu # Set the base image to Ubuntu
...@@ -10,8 +10,10 @@ ENV PYTHONUNBUFFERED 1 ...@@ -10,8 +10,10 @@ ENV PYTHONUNBUFFERED 1
RUN apt-get update RUN apt-get update
RUN apt-get install netcat libgdal1h libproj-dev proj-data proj-bin -y RUN apt-get install netcat libgdal1h libproj-dev proj-data proj-bin -y
RUN mkdir /whats_open # Copy over all project files into /whats_open
WORKDIR /whats_open RUN mkdir /whats-open/
ADD /requirements/ /whats_open/ WORKDIR /whats-open/
RUN pip install -r base.txt ADD . /whats-open/
ADD . /whats_open/
# Pip install all required dependecies
RUN pip install -r /whats-open/requirements/base.txt
# What's Open # What's Open
[![build status](https://git.gmu.edu/srct/whats-open/badges/master/build.svg)](https://git.gmu.edu/srct/whats-open/commits/master) [![coverage report](https://git.gmu.edu/srct/whats-open/badges/master/coverage.svg)](https://git.gmu.edu/srct/whats-open/commits/master) [![python version](https://img.shields.io/badge/python-2.7-blue.svg)]() [![Django version](https://img.shields.io/badge/Django-1.10-brightgreen.svg)]() [![build status](https://git.gmu.edu/srct/whats-open/badges/master/build.svg)](https://git.gmu.edu/srct/whats-open/commits/master) [![coverage report](https://git.gmu.edu/srct/whats-open/badges/master/coverage.svg)](https://git.gmu.edu/srct/whats-open/commits/master) [![python version](https://img.shields.io/badge/python-3.5+-blue.svg)]() [![Django version](https://img.shields.io/badge/Django-2.0-brightgreen.svg)]()
The What's Open project is an initiative at George Mason University by Mason The What's Open project is an initiative at George Mason University by Mason
Student Run Computing and Technology (SRCT) to display which dining locations Student Run Computing and Technology (SRCT) to display which dining locations
...@@ -121,19 +121,17 @@ Additionally, you will need to install docker-compose: https://docs.docker.com/c ...@@ -121,19 +121,17 @@ Additionally, you will need to install docker-compose: https://docs.docker.com/c
Next inside the `whats-open/` root directory run: Next inside the `whats-open/` root directory run:
docker-compose build docker build . -t 'whats-open-api'
If that doesn't work, try: This builds the docker image that we will deploy to the swarm in a stack.
sudo docker-compose build Initialize your swarm:
Then, follow up with: docker swarm init
docker-compose up And finally,
If that doesn't work, try: docker stack deploy whats-open-api_stack -c docker-compose.yml
sudo docker-compose build
You should see that the server is running by going to http://localhost:8000 You should see that the server is running by going to http://localhost:8000
in your browser. Any changes you make to your local file system will be mirrored in the server. in your browser. Any changes you make to your local file system will be mirrored in the server.
...@@ -306,11 +304,6 @@ If you're new to Django and don't know where to start, I highly recommend ...@@ -306,11 +304,6 @@ If you're new to Django and don't know where to start, I highly recommend
giving the [tutorial](https://docs.djangoproject.com/en/dev/intro/tutorial01/) giving the [tutorial](https://docs.djangoproject.com/en/dev/intro/tutorial01/)
a try. However, it leaves out a lot of important things, so remember, Google is a try. However, it leaves out a lot of important things, so remember, Google is
your friend. your friend.
For the JavaScript, I will be using jQuery whenever possible because I prefer
it to straight up JavaScript. jQuery has [great
documentation](http://docs.jquery.com/) and I've found [Mozilla's documentation
on JavaScript](https://developer.mozilla.org/en-US/docs/JavaScript) to be
useful as well. But if your Google-fu is sharp, that should suffice.
## CONTRIBUTING.md ## CONTRIBUTING.md
......
version: '2' version: '3'
services: services:
wopen_web: db:
build: . image: mysql
restart: always deploy:
replicas: 1
restart_policy:
condition: on-failure
networks:
- wopen_net
ports:
- "3306:3306"
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_DATABASE: wopen
MYSQL_USER: wopen
MYSQL_PASSWORD: wopen
api:
image: whats-open-api
deploy:
replicas: 1
restart_policy:
condition: on-failure
networks:
- wopen_net
ports: ports:
- '8000:8000' - '8000:8000'
command: /bin/bash ./startup.sh command: /bin/bash ./startup.sh
volumes: volumes:
- .:/whats_open - .:/whats-open
depends_on: depends_on:
- wopen_db - db
environment: environment:
- WOPEN_EMAIL_DOMAIN=@masonlive.gmu.edu - WOPEN_EMAIL_DOMAIN=@masonlive.gmu.edu
- WOPEN_DB_NAME=wopen - WOPEN_DB_NAME=wopen
- WOPEN_DB_USER=wopen - WOPEN_DB_USER=wopen
- WOPEN_DB_PASSWORD=wopen - WOPEN_DB_PASSWORD=wopen
- WOPEN_DB_HOST=wopen_db - WOPEN_DB_HOST=db
- WOPEN_DB_PORT=3306 - WOPEN_DB_PORT=3306
- WOPEN_SUPERUSER=admin - WOPEN_SUPERUSER=admin
wopen_db:
image: mysql networks:
environment: wopen_net:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes' \ No newline at end of file
MYSQL_DATABASE: wopen
MYSQL_USER: wopen
MYSQL_PASSWORD: wopen
\ No newline at end of file
Django >= 1.11, < 2.0 Django >= 2.0, < 2.1
django-autoslug==1.9.3 django-autoslug-iplweb
django-cas-client==1.3.0 django-cas-client==1.3.0
djangorestframework==3.6.3 djangorestframework==3.7.7
django-model-utils==3.0.0 django-model-utils==3.0.0
mysqlclient==1.3.10 mysqlclient==1.3.12
setuptools==36.2.0 setuptools==36.2.0
django-taggit==0.22.1 django-taggit==0.22.2
django-taggit-serializer==0.1.5 django-taggit-serializer==0.1.5
six==1.10.0 djangorestframework-gis==0.12.0
djangorestframework-gis==0.11.2
django-filter==1.0.4 django-filter==1.0.4
django-crispy-forms==1.6.1 django-crispy-forms==1.7.0
markdown==2.6.8 markdown==2.6.10
coreapi==2.3.1 coreapi==2.3.3
urllib3==1.22 urllib3==1.22
docutils==0.13.1 docutils==0.13.1
\ No newline at end of file
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
# production that isn't in development. # production that isn't in development.
-r base.txt -r base.txt
gunicorn>=19.0,<20.0 gunicorn
\ No newline at end of file \ No newline at end of file
until nc -z wopen_db 3306; do #!/bin/sh
until nc -z db 3306; do
echo "waiting for database to start..." echo "waiting for database to start..."
sleep 1 sleep 1
done done
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" """
api/admin.py api/admin.py
...@@ -5,16 +7,13 @@ Django admin interface configuration. ...@@ -5,16 +7,13 @@ Django admin interface configuration.
https://docs.djangoproject.com/en/1.11/ref/contrib/admin/ https://docs.djangoproject.com/en/1.11/ref/contrib/admin/
""" """
# Future Imports
from __future__ import (absolute_import, division, print_function,
unicode_literals)
# Django Imports # Django Imports
from django.contrib import admin from django.contrib import admin
from django.contrib.gis.admin import OSMGeoAdmin from django.contrib.gis.admin import OSMGeoAdmin
# App Imports # App Imports
from .models import Facility, Schedule, OpenTime, Category, Location, Alert from .models import Facility, Schedule, OpenTime, Category, Location, Alert
@admin.register(Facility)
class FacilityAdmin(admin.ModelAdmin): class FacilityAdmin(admin.ModelAdmin):
""" """
Custom Admin panel for the Facility model. Custom Admin panel for the Facility model.
...@@ -27,9 +26,11 @@ class FacilityAdmin(admin.ModelAdmin): ...@@ -27,9 +26,11 @@ class FacilityAdmin(admin.ModelAdmin):
# We are basically reordering things to look nicer to the user here # We are basically reordering things to look nicer to the user here
fieldsets = ( fieldsets = (
(None, { (None, {
'fields': ('facility_name', 'facility_category', 'facility_location', 'fields': ('facility_name', 'logo', 'facility_category',
'main_schedule', 'special_schedules', 'facility_location', 'main_schedule', 'special_schedules',
'facility_product_tags', 'tapingo_url', 'owners'), ('facility_product_tags', 'facility_labels',
'facility_classifier'),
'tapingo_url', 'phone_number', 'note', 'owners'),
}), }),
) )
...@@ -43,7 +44,18 @@ class OpenTimeInline(admin.TabularInline): ...@@ -43,7 +44,18 @@ class OpenTimeInline(admin.TabularInline):
model = OpenTime model = OpenTime
# 7 days of the week, so only have 7 rows # 7 days of the week, so only have 7 rows
max_num = 7 max_num = 7
extra = 7
# We are basically reordering things to look nicer to the user here
fieldsets = (
(None, {
'fields': (
('start_day', 'start_time'),
('end_day', 'end_time')
),
}),
)
@admin.register(Schedule)
class ScheduleAdmin(admin.ModelAdmin): class ScheduleAdmin(admin.ModelAdmin):
""" """
Custom Admin panel for the Schedule model. Custom Admin panel for the Schedule model.
...@@ -63,15 +75,16 @@ class ScheduleAdmin(admin.ModelAdmin): ...@@ -63,15 +75,16 @@ class ScheduleAdmin(admin.ModelAdmin):
# Pair valid_start and valid_end together on the same # Pair valid_start and valid_end together on the same
# line # line
('valid_start', 'valid_end'), ('valid_start', 'valid_end'),
'twenty_four_hours') 'twenty_four_hours',
'schedule_for_removal',
'promote_to_main')
}), }),
) )
# Register the custom administration panels
# https://docs.djangoproject.com/en/1.11/ref/contrib/admin/#modeladmin-objects
admin.site.register(Facility, FacilityAdmin)
admin.site.register(Schedule, ScheduleAdmin)
# https://docs.djangoproject.com/en/1.11/ref/contrib/gis/admin/#osmgeoadmin # https://docs.djangoproject.com/en/1.11/ref/contrib/gis/admin/#osmgeoadmin
OSMGeoAdmin.default_lon = -8605757.16502
OSMGeoAdmin.default_lat = 4697457.00333
OSMGeoAdmin.default_zoom = 15
admin.site.register(Location, OSMGeoAdmin) admin.site.register(Location, OSMGeoAdmin)
# Use the default ModelAdmin interface for these # Use the default ModelAdmin interface for these
admin.site.register(Category) admin.site.register(Category)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" """
api/models.py api/models.py
...@@ -6,10 +8,6 @@ the API. ...@@ -6,10 +8,6 @@ the API.
https://docs.djangoproject.com/en/1.11/topics/db/models/ https://docs.djangoproject.com/en/1.11/topics/db/models/
""" """
# Future Imports
from __future__ import (absolute_import, division, print_function,
unicode_literals)
# Python stdlib Imports # Python stdlib Imports
import datetime import datetime
...@@ -24,6 +22,7 @@ from django.utils import timezone ...@@ -24,6 +22,7 @@ from django.utils import timezone
from model_utils.models import TimeStampedModel from model_utils.models import TimeStampedModel
from autoslug import AutoSlugField from autoslug import AutoSlugField
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from taggit.models import GenericTaggedItemBase, TagBase
class Category(TimeStampedModel): class Category(TimeStampedModel):
""" """
...@@ -63,6 +62,10 @@ class Location(TimeStampedModel): ...@@ -63,6 +62,10 @@ class Location(TimeStampedModel):
) )
# The building that the facility is located in (on campus). # The building that the facility is located in (on campus).
building = models.CharField(max_length=100) building = models.CharField(max_length=100)
friendly_building = models.CharField('Building Abbreviation',
help_text="Example: Exploratory Hall becomes EXPL",
blank=True,
max_length=10)
# The physical address of the facility. # The physical address of the facility.
address = models.CharField(max_length=100) address = models.CharField(max_length=100)
campus_region = models.CharField(choices=CAMPUS_LOCATIONS, campus_region = models.CharField(choices=CAMPUS_LOCATIONS,
...@@ -84,6 +87,15 @@ class Location(TimeStampedModel): ...@@ -84,6 +87,15 @@ class Location(TimeStampedModel):
self.address, self.address,
self.on_campus) self.on_campus)
# 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
class StupidLabelHack(GenericTaggedItemBase):
tag = models.ForeignKey(StupidFacilityLabelHack, on_delete=models.CASCADE)
class Facility(TimeStampedModel): class Facility(TimeStampedModel):
""" """
Represents a specific facility location. A Facility is some type of Represents a specific facility location. A Facility is some type of
...@@ -97,17 +109,30 @@ class Facility(TimeStampedModel): ...@@ -97,17 +109,30 @@ class Facility(TimeStampedModel):
# The category that this facility can be grouped with # The category that this facility can be grouped with
facility_category = models.ForeignKey('Category', facility_category = models.ForeignKey('Category',
related_name="categories") related_name="categories",
on_delete=models.CASCADE)
# The location object that relates to this facility # The location object that relates to this facility
facility_location = models.ForeignKey('Location', facility_location = models.ForeignKey('Location',
related_name="facilities") related_name="facilities",
on_delete=models.CASCADE)
# A note that can be left on a Facility to provide some additional
# information.
note = models.TextField('Facility Note', blank=True,
help_text="Additional information that is sent with this Facility.")
# A link to the logo image for this Facility
logo = models.URLField('Logo URL', blank=True,
default="https://imgur.com/q2Phkn9.png",
help_text="The absolute URL to the logo image for this Facility.")
# The User(s) that claim ownership over this facility # The User(s) that claim ownership over this facility
owners = models.ManyToManyField(User) owners = models.ManyToManyField(User)
# The schedule that is defaulted to if no special schedule is in effect # The schedule that is defaulted to if no special schedule is in effect
main_schedule = models.ForeignKey('Schedule', main_schedule = models.ForeignKey('Schedule',
related_name='facility_main') related_name='facility_main',
on_delete=models.CASCADE)
# A schedule that has a specific start and end date # A schedule that has a specific start and end date
special_schedules = models.ManyToManyField('Schedule', special_schedules = models.ManyToManyField('Schedule',
related_name='facility_special', related_name='facility_special',
...@@ -119,9 +144,31 @@ class Facility(TimeStampedModel): ...@@ -119,9 +144,31 @@ class Facility(TimeStampedModel):
tapingo_url = models.URLField(blank=True, validators=[RegexValidator(regex='^https:\/\/www.tapingo.com\/', 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/', message='The link is not a valid tapingo link. Example: https://www.tapingo.com/order/restaurant/starbucks-gmu-johnson/',
code='invalid_tapingo_url')]) code='invalid_tapingo_url')])
# A comma seperate list of words that neatly an aptly describe the product
# Phone number for a location if provided. Accept both ###-###-#### or
# without dashes
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')])
# A comma seperate list of words that neatly and aptly describe the product
# that this facility produces. (ex. for Taco Bell: mexican, taco, cheap) # that this facility produces. (ex. for Taco Bell: mexican, taco, cheap)
facility_product_tags = TaggableManager() # These words are not shown to the use but are rather used in search.
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.")
# Labels to describe the Facility that are displayed to the user and can be
# informative. "Takes Mason Money"
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.")
# Tag a Facility to be shown on the ShopMason or Sodoxo (or both)
# What's Open sites.
FACILITY_CLASSES = (
("shopmason", "shopMason Facility"),
("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)
def is_open(self): def is_open(self):
""" """
...@@ -155,15 +202,18 @@ class Facility(TimeStampedModel): ...@@ -155,15 +202,18 @@ class Facility(TimeStampedModel):
# Closed # Closed
return False return False
def clean_special_schedules(self): def clean_schedules(self):
""" """
Loop through every special_schedule and remove entries that have Loop through every special_schedule and remove entries that have
expired. expired as well as promote special schedules to main if necessary.
""" """
for special_schedule in self.special_schedules.all(): for special_schedule in self.special_schedules.all():
# If it ends before today # If it ends before today
if special_schedule.valid_end < datetime.date.today(): if special_schedule.valid_end < datetime.date.today() and special_schedule.schedule_for_removal:
self.special_schedules.remove(special_schedule) self.special_schedules.remove(special_schedule)
elif special_schedule.promote_to_main:
if special_schedule.valid_start < datetime.date.today() and special_schedule.valid_end >= datetime.date.today():
self.main_schedule = special_schedule
class Meta: class Meta:
verbose_name = "facility" verbose_name = "facility"
...@@ -187,16 +237,27 @@ class Schedule(TimeStampedModel): ...@@ -187,16 +237,27 @@ class Schedule(TimeStampedModel):
# The start date of the schedule # The start date of the schedule
# (inclusive) # (inclusive)
valid_start = models.DateField('Start Date', null=True, blank=True, valid_start = models.DateTimeField('Start Date', null=True, blank=True,
help_text="Date that this schedule goes into effect") help_text="Date & time that this schedule goes into effect")
# The end date of the schedule # The end date of the schedule
# (inclusive) # (inclusive)
valid_end = models.DateField('End Date', null=True, blank=True, valid_end = models.DateTimeField('End Date', null=True, blank=True,
help_text="Last day that this schedule is in effect") help_text="Last date & time that this schedule is in effect")
# Boolean for if this schedule is 24 hours # Boolean for if this schedule is 24 hours
twenty_four_hours = models.BooleanField('24 hour schedule?', blank=True, 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.") 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.")
# Boolean for if this schedule should never be removed.
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.")
# Boolean for if this schedule should become the main schedule at the point
# it goes live
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.")
def is_open_now(self): def is_open_now(self):
""" """
Return true if this schedule is open right now. Return true if this schedule is open right now.
...@@ -254,7 +315,8 @@ class OpenTime(TimeStampedModel): ...@@ -254,7 +315,8 @@ class OpenTime(TimeStampedModel):
) )
# The schedule that this period of open time is a part of # The schedule that this period of open time is a part of
schedule = models.ForeignKey('Schedule', related_name='open_times') schedule = models.ForeignKey('Schedule', related_name='open_times',
on_delete=models.CASCADE)
# The day that the open time begins on # The day that the open time begins on
start_day = models.IntegerField(default=0, choices=DAY_CHOICES) start_day = models.IntegerField(default=0, choices=DAY_CHOICES)
......
<
#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" """