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:
- 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
whats-open-py2.7:
image: library/python:2.7
type: test
script:
- python manage.py test
whats-open-py3.5:
image: library/python:3.5
type: test
......
## Summary
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,
especially someone new, understand the goal of the issue and how they should best
approach the problem.
## Helpful Links
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.
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
......@@ -10,8 +10,10 @@ ENV PYTHONUNBUFFERED 1
RUN apt-get update
RUN apt-get install netcat libgdal1h libproj-dev proj-data proj-bin -y
RUN mkdir /whats_open
WORKDIR /whats_open
ADD /requirements/ /whats_open/
RUN pip install -r base.txt
ADD . /whats_open/
# Copy over all project files into /whats_open
RUN mkdir /whats-open/
WORKDIR /whats-open/
ADD . /whats-open/
# Pip install all required dependecies
RUN pip install -r /whats-open/requirements/base.txt
# 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
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
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:
sudo docker-compose build
docker stack deploy whats-open-api_stack -c docker-compose.yml
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.
......@@ -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/)
a try. However, it leaves out a lot of important things, so remember, Google is
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
......
version: '2'
version: '3'
services:
wopen_web:
build: .
restart: always
db:
image: mysql
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:
- '8000:8000'
command: /bin/bash ./startup.sh
volumes:
- .:/whats_open
- .:/whats-open
depends_on:
- wopen_db
- db
environment:
- WOPEN_EMAIL_DOMAIN=@masonlive.gmu.edu
- WOPEN_DB_NAME=wopen
- WOPEN_DB_USER=wopen
- WOPEN_DB_PASSWORD=wopen
- WOPEN_DB_HOST=wopen_db
- WOPEN_DB_HOST=db
- WOPEN_DB_PORT=3306
- WOPEN_SUPERUSER=admin
wopen_db:
image: mysql
environment:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_DATABASE: wopen
MYSQL_USER: wopen
MYSQL_PASSWORD: wopen
\ No newline at end of file
networks:
wopen_net:
\ No newline at end of file
Django >= 1.11, < 2.0
django-autoslug==1.9.3
Django >= 2.0, < 2.1
django-autoslug-iplweb
django-cas-client==1.3.0
djangorestframework==3.6.3
djangorestframework==3.7.7
django-model-utils==3.0.0
mysqlclient==1.3.10
mysqlclient==1.3.12
setuptools==36.2.0
django-taggit==0.22.1
django-taggit==0.22.2
django-taggit-serializer==0.1.5
six==1.10.0
djangorestframework-gis==0.11.2
djangorestframework-gis==0.12.0
django-filter==1.0.4
django-crispy-forms==1.6.1
markdown==2.6.8
coreapi==2.3.1
django-crispy-forms==1.7.0
markdown==2.6.10
coreapi==2.3.3
urllib3==1.22
docutils==0.13.1
\ No newline at end of file
......@@ -2,4 +2,4 @@
# production that isn't in development.
-r base.txt
gunicorn>=19.0,<20.0
\ No newline at end of file
gunicorn
\ 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..."
sleep 1
done
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
api/admin.py
......@@ -5,16 +7,13 @@ Django admin interface configuration.
https://docs.djangoproject.com/en/1.11/ref/contrib/admin/
"""
# Future Imports
from __future__ import (absolute_import, division, print_function,
unicode_literals)
# Django Imports
from django.contrib import admin
from django.contrib.gis.admin import OSMGeoAdmin
# App Imports
from .models import Facility, Schedule, OpenTime, Category, Location, Alert
@admin.register(Facility)
class FacilityAdmin(admin.ModelAdmin):
"""
Custom Admin panel for the Facility model.
......@@ -27,9 +26,11 @@ class FacilityAdmin(admin.ModelAdmin):
# We are basically reordering things to look nicer to the user here
fieldsets = (
(None, {
'fields': ('facility_name', 'facility_category', 'facility_location',
'main_schedule', 'special_schedules',
'facility_product_tags', 'tapingo_url', 'owners'),
'fields': ('facility_name', 'logo', 'facility_category',
'facility_location', 'main_schedule', 'special_schedules',
('facility_product_tags', 'facility_labels',
'facility_classifier'),
'tapingo_url', 'phone_number', 'note', 'owners'),
}),
)
......@@ -43,7 +44,18 @@ class OpenTimeInline(admin.TabularInline):
model = OpenTime
# 7 days of the week, so only have 7 rows
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):
"""
Custom Admin panel for the Schedule model.
......@@ -63,15 +75,16 @@ class ScheduleAdmin(admin.ModelAdmin):
# Pair valid_start and valid_end together on the same
# line
('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
OSMGeoAdmin.default_lon = -8605757.16502
OSMGeoAdmin.default_lat = 4697457.00333
OSMGeoAdmin.default_zoom = 15
admin.site.register(Location, OSMGeoAdmin)
# Use the default ModelAdmin interface for these
admin.site.register(Category)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
api/models.py
......@@ -6,10 +8,6 @@ the API.
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
import datetime
......@@ -24,6 +22,7 @@ from django.utils import timezone
from model_utils.models import TimeStampedModel
from autoslug import AutoSlugField
from taggit.managers import TaggableManager
from taggit.models import GenericTaggedItemBase, TagBase
class Category(TimeStampedModel):
"""
......@@ -63,6 +62,10 @@ class Location(TimeStampedModel):
)
# The building that the facility is located in (on campus).
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.
address = models.CharField(max_length=100)
campus_region = models.CharField(choices=CAMPUS_LOCATIONS,
......@@ -84,6 +87,15 @@ class Location(TimeStampedModel):
self.address,
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):
"""
Represents a specific facility location. A Facility is some type of
......@@ -97,17 +109,30 @@ class Facility(TimeStampedModel):
# The category that this facility can be grouped with
facility_category = models.ForeignKey('Category',
related_name="categories")
related_name="categories",
on_delete=models.CASCADE)
# The location object that relates to this facility
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
owners = models.ManyToManyField(User)
# The schedule that is defaulted to if no special schedule is in effect
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
special_schedules = models.ManyToManyField('Schedule',
related_name='facility_special',
......@@ -119,9 +144,31 @@ class Facility(TimeStampedModel):
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')])
# 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)
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):
"""
......@@ -155,15 +202,18 @@ class Facility(TimeStampedModel):
# Closed
return False
def clean_special_schedules(self):
def clean_schedules(self):
"""
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():
# 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)
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:
verbose_name = "facility"
......@@ -187,16 +237,27 @@ class Schedule(TimeStampedModel):
# The start date of the schedule
# (inclusive)
valid_start = models.DateField('Start Date', null=True, blank=True,
help_text="Date that this schedule goes into effect")
valid_start = models.DateTimeField('Start Date', null=True, blank=True,
help_text="Date & time that this schedule goes into effect")
# The end date of the schedule
# (inclusive)
valid_end = models.DateField('End Date', null=True, blank=True,
help_text="Last day that this schedule is in effect")
valid_end = models.DateTimeField('End Date', null=True, blank=True,
help_text="Last date & time that this schedule is in effect")
# Boolean for if this schedule is 24 hours
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.")
# 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):
"""
Return true if this schedule is open right now.
......@@ -254,7 +315,8 @@ class OpenTime(TimeStampedModel):
)
# 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
start_day = models.IntegerField(default=0, choices=DAY_CHOICES)
......
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
api/serializers.py
......@@ -6,10 +8,6 @@ can then be easily rendered into JSON, XML or other content types.
http://www.django-rest-framework.org/api-guide/serializers
"""
# Future Imports
from __future__ import (absolute_import, division, print_function,
unicode_literals)
# App Imports
from .models import Category, Facility, Schedule, OpenTime, Location, Alert
......@@ -66,7 +64,7 @@ class ScheduleSerializer(serializers.ModelSerializer):
model = Schedule
# List the fields that we are serializing
fields = ('id', 'open_times', 'modified', 'name', 'valid_start',
'valid_end', 'twenty_four_hours')
'valid_end', 'twenty_four_hours', 'schedule_for_removal', 'promote_to_main')
class FacilitySerializer(serializers.HyperlinkedModelSerializer):
"""
......@@ -78,23 +76,20 @@ class FacilitySerializer(serializers.HyperlinkedModelSerializer):
than primary keys.
http://www.django-rest-framework.org/api-guide/serializers/#hyperlinkedmodelserializer
"""
# Append a serialized Category object
# Append serialized objects
facility_category = CategorySerializer(many=False, read_only=True)
# Append a serialized Location object
facility_location = LocationSerializer(many=False, read_only=True)
# Append a serialized Schedule object to represent main_schedule
main_schedule = ScheduleSerializer(many=False, read_only=True)
# Append a serialized Schedule object to represent special_schedules
special_schedules = ScheduleSerializer(many=True, read_only=True)
# Append a serialized TagList object that represents the product tags for a
# Facility
facility_product_tags = TagListSerializerField()
facility_labels = TagListSerializerField()
facility_classifier = TagListSerializerField()
class Meta:
# Choose the model to be serialized
model = Facility
# List the fields that we are serializing
fields = ('slug', 'facility_name', 'facility_location', 'facility_category',
'facility_product_tags', 'tapingo_url',
'main_schedule', 'special_schedules',
'modified', )
fields = ('slug', 'facility_name', 'logo', 'facility_location',
'facility_category', 'facility_product_tags',
'facility_labels', 'facility_classifier', 'tapingo_url',
'note', 'main_schedule', 'special_schedules', 'modified', )
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
api/urls.py
......@@ -5,12 +7,8 @@ Define the routes that the API will serve content through.
http://www.django-rest-framework.org/api-guide/routers/
"""
# Future Imports
from __future__ import (absolute_import, division, print_function,
unicode_literals)
# Django Imports
from django.conf.urls import include, url
from django.urls import include, path
from django.views.generic.base import RedirectView
# App Imports
......@@ -33,7 +31,7 @@ ROUTER.register(r'schedules', ScheduleViewSet, 'schedule')
urlpatterns = [
# / - Default route
# We redirect to /api since this is in reality the default page for the API
url(r'^$', RedirectView.as_view(url='/api')),
path('', RedirectView.as_view(url='/api')),
# /api - Root API URL
url(r'^api/', include(ROUTER.urls)),
path('api/', include(ROUTER.urls)),
]
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
api/views.py
......@@ -6,10 +8,6 @@ Rest Framework Class Views
Each ViewSet determines what data is returned when an API endpoint is hit. In
addition, we define filtering and documentation for each of these endpoints.
"""
# Future Imports
from __future__ import (absolute_import, division, print_function,
unicode_literals)
# Python std. lib. imports
import datetime
......@@ -124,11 +122,12 @@ class AlertViewSet(viewsets.ReadOnlyModelViewSet):
return Alert.objects.all()
# Default behavior
else:
alertable = []
# Enumerate all Alert objects that are active
for alert in Alert.objects.all():
if alert.is_active():
alertable.append(alert.pk)
alertable = [
alert.pk
for alert in Alert.objects.all()
if alert.is_active()
]
# Return active Alerts
return Alert.objects.filter(pk__in=alertable)
......@@ -269,6 +268,7 @@ class LocationViewSet(viewsets.ReadOnlyModelViewSet):
FILTER_FIELDS = (
# Location fields
'building',
'friendly_building',
'address',
'on_campus',
'campus_region'
......@@ -361,12 +361,17 @@ class FacilityViewSet(viewsets.ReadOnlyModelViewSet):
FILTER_FIELDS = (
# Facility fields
'facility_name',
'facility_classifier',
'logo',
'tapingo_url',
'note',
'facility_product_tags__name',
'facility_labels__name',
# Category fields
'facility_category__name',
# Location fields
'facility_location__building',
'facility_location__friendly_building',
'facility_location__address',
'facility_location__on_campus',
'facility_location__campus_region',
......@@ -375,10 +380,12 @@ class FacilityViewSet(viewsets.ReadOnlyModelViewSet):
'main_schedule__valid_start',
'main_schedule__valid_end',
'main_schedule__twenty_four_hours',