Skip to content

Commit

Permalink
Merge pull request #20 from filips123/ical
Browse files Browse the repository at this point in the history
ical & rss support
  • Loading branch information
filips123 authored Aug 30, 2021
2 parents 6bd8830 + f631346 commit ffc75ad
Show file tree
Hide file tree
Showing 37 changed files with 4,103 additions and 4,656 deletions.
12 changes: 4 additions & 8 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,9 @@ insert_final_newline = true
trim_trailing_whitespace = true

# Web & Data
[*.{html,css,sass,scss,js,jsx,ts,tsx,vue,json,yaml,yml}]
[*.{html,css,sass,scss,xml,js,jsx,jsm,ts,tsx,vue,json,yaml,yml,webmanifest}]
indent_size = 2

# Windows
[*.{bat,cmd,ps1}]
end_of_line = crlf

# Markdown & reStructuredText & AsciiDoc
[*.{md,rst,adoc}]
trim_trailing_whitespace = false
# Configuration
[.*rc]
indent_size = 2
4 changes: 4 additions & 0 deletions API/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ You can also use any other WSGI-compatible server. See [Flask Documentation](htt

You can retrieve all API routes using the `gimvicurnik routes` commands. The official client can be found in [in the `website` directory](../website).

### Debugging

If you enable `debug` in the config file, another command `gimvicurnik create-substitutions`. This commands can be used to generate random substitutions in the next 14 days.

## Contributing

The API uses FlakeHell and Blake for linting the code. They are included in project's development dependencies.
Expand Down
50 changes: 48 additions & 2 deletions API/config.yaml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ sources:
menu:
url: https://www.gimvic.org/delovanjesole/solske_sluzbe_in_solski_organi/solska_prehrana/

urls:
website: https://gimvicurnik.filips.si
api: https://api.gimvicurnik.filips.si

database: sqlite:///app.db

cors:
Expand All @@ -24,8 +28,8 @@ sentry:
maxBreadcrumbs: 100
sampleRate:
commands: 0.5
requests: 0.25
other: 0.25
requests: 0.5
other: 0.5

logging:
version: 1
Expand All @@ -42,3 +46,45 @@ logging:
level: INFO
handlers:
- console

hourtimes:
- hour:
name: "predura"
start: "0710"
end: "0755"
- hour:
name: "1. ura"
start: "0800"
end: "0845"
- hour:
name: "2. ura"
start: "0850"
end: "0935"
- hour:
name: "3. ura"
start: "0940"
end: "1025"
- hour:
name: "4. ura"
start: "1055"
end: "1140"
- hour:
name: "5. ura"
start: "1145"
end: "1230"
- hour:
name: "6. ura"
start: "1235"
end: "1320"
- hour:
name: "7. ura"
start: "1325"
end: "1410"
- hour:
name: "8. ura"
start: "1415"
end: "1500"
- hour:
name: "9. ura"
start: "1505"
end: "1550"
141 changes: 137 additions & 4 deletions API/gimvicurnik/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import os
from datetime import datetime, timedelta

import yaml
from flask import Flask, jsonify, request
from flask import Flask, jsonify, render_template, request
from schema import Optional, Or, Schema, SchemaError
from sqlalchemy import create_engine
from sqlalchemy import create_engine, or_
from sqlalchemy.orm import scoped_session
from werkzeug.exceptions import HTTPException

from .commands import (
cleanup_database_command,
create_database_command,
create_substitutions_command,
update_eclassroom_command,
update_menu_command,
update_timetable_command,
)
from .database import Class, Classroom, Document, Entity, LunchMenu, LunchSchedule, Session, SnackMenu, Teacher
from .errors import ConfigError, ConfigParseError, ConfigReadError, ConfigValidationError
from .utils.flask import DateConverter, ListConverter
from .utils.ical import create_calendar
from .utils.url import tokenize_url


Expand Down Expand Up @@ -47,6 +50,10 @@ class GimVicUrnik:
"url": str,
},
},
"urls": {
"website": str,
"api": str,
},
"database": str,
Optional("sentry"): {
"dsn": str,
Expand All @@ -63,6 +70,16 @@ class GimVicUrnik:
},
Optional("logging"): Or(dict, str),
Optional("cors"): [str],
"hourtimes": [
{
"hour": {
"name": str,
"start": str,
"end": str,
}
},
],
Optional("debug", default=False): bool,
}
)

Expand All @@ -83,11 +100,10 @@ def __init__(self, configfile):
self.configure_logging()
self.configure_sentry()

self.session: scoped_session = None # type: ignore
self.engine = create_engine(self.config["database"])
Session.configure(bind=self.engine)

self.session = None

self.app = Flask("gimvicurnik", static_folder=None)
self.app.gimvicurnik = self

Expand All @@ -96,6 +112,8 @@ def __init__(self, configfile):
self.create_database_hooks()
self.create_cors_hooks()

self.convert_date_objects()

self.register_route_converters()
self.register_commands()
self.register_routes()
Expand Down Expand Up @@ -223,6 +241,15 @@ def _apply_cors(response):

return response

def convert_date_objects(self):
"""Convert %H%M notation to Python `datetime` object for all hour times."""

for hour in self.config["hourtimes"]:
date_start = datetime.strptime(hour["hour"]["start"], "%H%M")
date_end = datetime.strptime(hour["hour"]["end"], "%H%M")
hour["hour"]["start"] = timedelta(hours=date_start.hour, minutes=date_start.minute)
hour["hour"]["end"] = timedelta(hours=date_end.hour, minutes=date_end.minute)

def register_route_converters(self):
"""Register all custom route converters."""

Expand All @@ -238,9 +265,34 @@ def register_commands(self):
self.app.cli.add_command(create_database_command)
self.app.cli.add_command(cleanup_database_command)

if "debug" in self.config and self.config["debug"]:
self.app.cli.add_command(create_substitutions_command)

def register_routes(self):
"""Register all application routes."""

def create_feed(filter, name, type, format):
query = (
self.session.query(Document.date, Document.type, Document.url, Document.description)
.filter(filter)
.order_by(Document.date)
)

content = render_template(
f"{format}.xml",
urls=self.config["urls"],
name=name,
type=type,
entries=query,
last_updated=max(model.date for model in query),
)

return (
content,
200,
{"Content-Type": f"application/{format}+xml; charset=utf-8"},
)

@self.app.route("/list/classes")
def _list_classes():
return jsonify([model.name for model in self.session.query(Class).order_by(Class.name)])
Expand Down Expand Up @@ -374,6 +426,87 @@ def _get_documents():
]
)

@self.app.route("/feeds/circulars.atom")
def _circulars_get_atom():
return create_feed(
filter=or_(Document.type == "circular", Document.type == "other"),
name="Okrožnice",
type="circulars",
format="atom",
)

@self.app.route("/feeds/circulars.rss")
def _circulars_get_rss():
return create_feed(
filter=or_(Document.type == "circular", Document.type == "other"),
name="Okrožnice",
type="circulars",
format="rss",
)

@self.app.route("/feeds/substitutions.atom")
def _substitutions_get_atom():
return create_feed(filter=Document.type == "substitutions", name="Nadomeščanja", type="substitutions", format="atom")

@self.app.route("/feeds/substitutions.rss")
def _substitutions_get_rss():
return create_feed(filter=Document.type == "substitutions", name="Nadomeščanja", type="substitutions", format="rss")

@self.app.route("/feeds/schedules.atom")
def _schedules_get_atom():
return create_feed(filter=Document.type == "lunch-schedule", name="Razporedi kosil", type="schedules", format="atom")

@self.app.route("/feeds/schedules.rss")
def _schedules_get_rss():
return create_feed(filter=Document.type == "lunch-schedule", name="Razporedi kosil", type="schedules", format="rss")

@self.app.route("/feeds/menus.atom")
def _menu_get_atom():
return create_feed(
filter=or_(Document.type == "snack-menu", Document.type == "lunch-menu"),
name="Jedilniki",
type="menus",
format="atom",
)

@self.app.route("/feeds/menus.rss")
def _menu_get_rss():
return create_feed(
filter=or_(Document.type == "snack-menu", Document.type == "lunch-menu"),
name="Jedilniki",
type="menus",
format="rss",
)

@self.app.route("/calendar/combined/<list:classes>")
def _get_calendar_for_classes(classes):
return create_calendar(
Class.get_substitutions(self.session, None, classes),
Class.get_lessons(self.session, classes),
self.config["hourtimes"],
f"Koledar - {', '.join(classes)} - Gimnazija Vič",
)

@self.app.route("/calendar/timetable/<list:classes>")
def _get_calendar_timetable_for_classes(classes):
return create_calendar(
Class.get_substitutions(self.session, None, classes),
Class.get_lessons(self.session, classes),
self.config["hourtimes"],
f"Urnik - {', '.join(classes)} - Gimnazija Vič",
substitutions=False,
)

@self.app.route("/calendar/substitutions/<list:classes>")
def _get_calendar_substitutions_for_classes(classes):
return create_calendar(
Class.get_substitutions(self.session, None, classes),
Class.get_lessons(self.session, classes),
self.config["hourtimes"],
f"Nadomeščanja - {', '.join(classes)} - Gimnazija Vič",
timetable=False,
)


def create_app():
"""Application factory that accepts a configuration file from environment variable."""
Expand Down
10 changes: 9 additions & 1 deletion API/gimvicurnik/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,22 @@ def _get_version(ctx, param, value):
requests_version = pkg_resources.get_distribution("requests").version
flask_version = pkg_resources.get_distribution("flask").version
pdf2docx_version = pkg_resources.get_distribution("pdf2docx").version
openpyxl_version = pkg_resources.get_distribution("openpyxl").version

try:
sentry_version = pkg_resources.get_distribution("sentry-sdk").version
except pkg_resources.DistributionNotFound:
sentry_version = "None"

click.echo(
f"Python: {python_version}\n"
f"GimVicUrnik: {gimvicurnik_version}\n"
f"SQLAlchemy: {sqlalchemy_version}\n"
f"Requests: {requests_version}\n"
f"Flask: {flask_version}\n"
f"pdf2docx: {pdf2docx_version}"
f"pdf2docx: {pdf2docx_version}\n"
f"openpyxl: {openpyxl_version}\n"
f"Sentry SDK: {sentry_version}"
)
ctx.exit()

Expand Down
14 changes: 13 additions & 1 deletion API/gimvicurnik/commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from flask.cli import with_appcontext

from ..database import Base, Class, Classroom, LunchMenu, LunchSchedule, SnackMenu, Substitution, Teacher
from ..updaters import EClassroomUpdater, MenuUpdater, TimetableUpdater
from ..updaters import EClassroomUpdater, MenuUpdater, TimetableUpdater, SubstitutionsGenerator
from ..utils.database import session_scope
from ..utils.sentry import with_transaction

Expand Down Expand Up @@ -82,3 +82,15 @@ def cleanup_database_command():
if len(model.lessons) == 0 and len(model.substitutions) == 0:
logging.getLogger(__name__).info("Removing the unused %s %s", model.__class__.__name__.lower(), model.name)
session.delete(model)


@click.command("create-substitutions", help="Create random substitutions")
@click.argument("number")
@with_transaction(name="create-substitutions", op="command")
@with_appcontext
def create_substitutions_command(number):
"""Create random substitutions in next 14 days."""

with session_scope() as session:
generator = SubstitutionsGenerator(session, number)
generator.generate()
5 changes: 4 additions & 1 deletion API/gimvicurnik/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Document(Base):

id = Column(Integer, primary_key=True)

# types - circular, other, substitutions, lunch-menu, snack-menu, lunch-schedule
date = Column(Date)
type = Column(Text)
url = Column(Text)
Expand Down Expand Up @@ -62,10 +63,12 @@ def get_substitutions(cls, session, date, names=None):
.join(original_classroom, Substitution.original_classroom_id == original_classroom.id, isouter=True)
.join(teacher, Substitution.teacher_id == teacher.id, isouter=True)
.join(classroom, Substitution.classroom_id == classroom.id, isouter=True)
.filter(Substitution.date == date)
.order_by(Substitution.day, Substitution.time)
)

if date:
query = query.filter(Substitution.date == date)

if names:
if cls.__tablename__ == "classes":
query = query.filter(Class.name.in_(names))
Expand Down
Loading

0 comments on commit ffc75ad

Please sign in to comment.