Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Master <- Develop #47

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# voctosched
Scripts for converting various formats to [frab](https://github.com/frab/frab)-style fahrplan XML.
This also may or may not be useful for users of [pentabarf](https://github.com/nevs/pentabarf).
Tool converting various formats to [frab](https://github.com/frab/frab)-style schedule XML and JSON as described by [in the c3voc wiki](https://c3voc.de/wiki/schedule). The format was initialy introduced by [pentabarf](https://github.com/nevs/pentabarf), got more popular with [frab](https://frab.github.io/frab/) and is also provided by [[https://pretalx.com/p/about/|pretalx]] – as it became the main interexchange format [between various systems](https://c3voc.de/wiki/api) recording and publishing talks of conferences and other events.

This repository implements an object representation of the frab export data model.
This can be used to build different im-/exporters or manipulators for the frab schedule export.

This repository implements an object representation of the frab data model and can be used to build different im-/exporters or manipulators.

There is also a number of application which can display the schedule on mobile devices or infoscreens.
Some examples are:
Expand All @@ -12,6 +11,8 @@ Some examples are:
* https://github.com/EventFahrplan/EventFahrplan
* https://github.com/Wilm0r/giggity

More information about the format, and other tools with different approaches can be found on https://c3voc.de/wiki/schedule#conversion_tools

# Usage
```
pip3 install --user -r requirements.txt
Expand All @@ -21,15 +22,17 @@ For more information about the input parameters and the generated output
have a look at the `demo` directory, this should be a good starting point.

# Currently supported inputs
* CSV
* JSON
* [CSV](demo/gpn11.csv)
* schedule JSON
* [Proyektor](demo/fusion22.json)

# Currently supported outputs
* basic XML, frab format
* extended XML, with additional `video_download_url` in events
* [schedule XML](https://github.com/voc/schedule/tree/master/validator/xsd)
* extended schedule XML, with additional `video_download_url` in events
* [schedule JSON](https://github.com/voc/schedule/tree/master/validator/json)

# Validator
The generated XML can be validated with the c3voc validator, which can be found here https://github.com/voc/schedule/tree/master/validator
The generated schedule XML can be validated with the c3voc validator, which can be found at https://github.com/voc/schedule/tree/master/validator and https://c3voc.de/schedulexml/

A quick validation can also be done with:

Expand Down
2 changes: 2 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import sys
sys.path.append('.')
15 changes: 14 additions & 1 deletion fahrplan/model/day.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import datetime as dt
import logging
from fahrplan.datetime import format_datetime
from fahrplan.datetime import format_datetime, parse_date, parse_datetime
from fahrplan.exception import FahrplanError
from fahrplan.xml import XmlWriter, XmlSerializable
from .event import Event
from .room import Room

log = logging.getLogger(__name__)


class Day(XmlSerializable):
def __init__(self, index: int, date: dt.date, start: dt.datetime = None, end: dt.datetime = None):
self.index = index
Expand All @@ -17,6 +18,18 @@ def __init__(self, index: int, date: dt.date, start: dt.datetime = None, end: dt
self.rooms = {}
self.schedule = None

@classmethod
def from_dict(cls, data: dict):
assert isinstance(data, dict), 'Data must be a dictionary.'

obj = Day(
index=data['index'],
date=parse_date(data['date']),
start=parse_datetime(data['start']),
end=parse_datetime(data['end'])
)
return obj

def add_room(self, room: Room):
if room.name not in self.rooms:
self.rooms[room.name] = room
Expand Down
62 changes: 58 additions & 4 deletions fahrplan/model/event.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from dataclasses import dataclass
from datetime import datetime, time, timedelta
import datetime as dt
from typing import Dict, Union, Callable
from typing import Dict, List, Union, Callable

from fahrplan.datetime import format_datetime, format_time, format_duration
from fahrplan.exception import FahrplanError
from binascii import crc32

from fahrplan.datetime import format_datetime, format_time, format_duration, parse_datetime, parse_duration, parse_time
from fahrplan.model.person import Person
from fahrplan.xml import XmlWriter, XmlSerializable
from ..uuid import uuid

Expand Down Expand Up @@ -41,6 +45,56 @@ def __init__(self, uid: int, date: dt.datetime, start: dt.time, duration: dt.tim
else:
self.slug = slug

@classmethod
def from_dict(cls, data: dict, pop_used_keys=False):
assert isinstance(data, dict), 'Data must be a dictionary.'

persons = {}
for person_info in data.get('persons', []):
person = Person.from_dict(person_info)
# generate some hopefully unique ids if they are 0
uid = person_info['id'] or (crc32(person.name.encode()) & 0xffffffff)
persons[uid] = person

links = {}
for link_info in data.get('links', []):
title = link_info['title']
# generate some hopefully unique ids if they are 0
url = link_info['url']
links[url] = title

attachments = {}
# TODO extract as util method
for attachment_info in data.get('attachments', []):
title = attachment_info['title']
# generate some hopefully unique ids if they are 0
url = attachment_info['url']
attachments[url] = title

obj = Event(
uid=data['id'],
guid=data['guid'],
date=parse_datetime(data['date']),
start=parse_time(data['start']),
duration=parse_duration(data['duration']),
slug=data['slug'],
title=data['title'],
subtitle=data.get('subtitle', ''),
abstract=data.get('abstract', ''),
description=data.get('description', ''),
language=data.get('language'),
persons=persons,
download_url=data.get('download_url', ''),
recording_license=data.get('recording_license', ''),
recording_optout=data['do_not_record'],
track=data.get('track', ''),
event_type=data.get('type', ''),
logo=data.get('logo', ''),
links=links,
attachments=attachments
)
return obj

def add_person(self, uid: int, name: str):
self.persons[uid] = name

Expand All @@ -50,7 +104,7 @@ def add_link(self, href: str, title: str):
def add_attachment(self, href: str, title: str):
self.attachments[href] = title

def append_xml(self, xml: XmlWriter, extended: bool):
def append_xml(self, xml: XmlWriter, extended: bool = False):
with xml.context("event", guid=self.guid, id=self.id):
xml.tag("date", format_datetime(self.date))
xml.tag("start", format_time(self.start))
Expand Down
102 changes: 102 additions & 0 deletions fahrplan/model/person.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from dataclasses import InitVar, dataclass, field, fields
from typing import Optional, Union
import uuid

from fahrplan.uuid import NAMESPACE_VOCTOSCHED


@dataclass
class Person:
"""A person e.g. speaker, contributor etc."""

name: str
"public name of the person"
guid: InitVar[Optional[str]] = None
"global unique idenifier, might be automatically computed from uri, email, id or code"
_guid: Optional[uuid.UUID] = field(init=False, repr=False, default=None)
"internal storage place if we got a precomputed person guid from origin system"
_uri: Optional[str] = field(init=False, repr=False, default=None)
"URL, URN or other URI identifing this person"
email: Optional[str] = None
"public email address, used as base for URI/GUID when nothing else is set"
id: Optional[int] = None
"deprecated: integer id of this person in the origin system"
code: Optional[str] = None
"deprecated: pretalx internal 5 char random id of this person in the origin system, e.g. `DEFAB`"

# avatar: Optional[str] = None
# biography: Optional[str] = None
# links: Optional[List[Any]] = None
# contacts: Optional[List[Any]] = None
# state: Optional[str] = None

origin_system: Optional[str] = None
"internet domain of system this entity originating from, e.g. `frab.cccv.de`"

def __post_init__(self, guid):
if type(guid) == str or type(guid) == uuid.UUID:
self._guid = guid

@classmethod
def from_dict(cls, data: dict):
assert isinstance(data, dict), 'Data must be a dictionary.'

# older schedule versions used other keys for public person name
name = data.get('name') or data.get('public_name') or data.get('full_public_name')

fieldSet = {f.name for f in fields(cls) if f.init and f.name != 'name'}
fieldSet.add('guid')
filteredData = {k: v for k, v in data.items() if k in fieldSet}

# support shorthand of previous `origin_system` key
if 'origin' in data:
filteredData['origin_system'] = data['origin']

return Person(name, **filteredData)

@property
def uri(self):
# if we got a precomputed person URID from the origin system, stay consistent
if self._uri:
return self._uri

if self.email:
return f"acct:{self.email}"

if (self.id or self.code) and self.origin_system:
# TODO person vs people
return f'urn:{self.origin_system}:person:{self.id or self.code}'

if self._guid:
return f'urn:uuid:{self._guid}'

return None

@uri.setter
def uri(self, value):
# TODO: raise exception if value is not an URI
self._uri = value

@property
def guid(self) -> str:
# if we got a precomputed person guid from the origin system, stay consistent
if self._guid:
return str(self._guid)

uri = self.uri
if uri:
return uuid.uuid5(uuid.NAMESPACE_URL, uri).__str__()

# we tried everything else, so fall back to hash of person name
return uuid.uuid5(NAMESPACE_VOCTOSCHED, self.name).__str__()

@guid.setter
def guid(self, value: Union[str, uuid.UUID]):
if type(value) != uuid.UUID:
self._guid = uuid.UUID(value)
else:
self._guid = value

# used resources:
# https://stackoverflow.com/a/61480946
# https://medium.com/swlh/python-dataclasses-with-properties-and-pandas-5c59b05e9131
27 changes: 26 additions & 1 deletion fahrplan/model/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,37 @@

log = logging.getLogger(__name__)


class Room(XmlSerializable):
def __init__(self, name: str):
def __init__(self, name: str, guid: str = None):
self.name = name
self.guid = guid
self.events = dict()
self.day = None

@classmethod
def from_dict(cls, data: dict, pop_used_keys=False):
"""
Loads an Room instance from the given dictionary.
An existing event can be provided which's data is overwritten (in parts).

:param data: a dictionary containing Room attributes' names as key (and their values)
:param pop_used_keys: Remove 'used' keys from the provided data. This can be used to detect additional/errornous fields.

"""
assert isinstance(data, dict), 'Data must be a dictionary.'

obj = Room(name=data['name'])
direct_fields = ['guid'] # 'description', 'blocked', 'capacity']
integer_fields = ['capacity']
for fld in direct_fields:
if fld in data:
value = data[fld] if not pop_used_keys else data.pop(fld)
if fld in integer_fields:
value = int(value)
setattr(obj, fld, value)
return obj

def add_event(self, event: Event):
event.room = self
self.events[event.id] = event
Expand Down
8 changes: 5 additions & 3 deletions fahrplan/model/schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ def add_room(self, name: str, day_filter: List[int] = None):
day.add_room(Room(name))

def add_event(self, day: int, room: str, event: Event):
# ensure that room exiists
self.add_room(room, day)
self.days[day].add_event(room, event)

def merge(self, other: 'Schedule'):
Expand All @@ -82,15 +84,15 @@ def has_collision(self, new_event: 'Event'):
for day in self.days.values():
for room in day.rooms.values():
for event in room.events.values():
if event.guid == new_event.guid:
log.error(f'Duplicate guid "{event.guid}"')
return True
if event.slug == new_event.slug:
log.error(f'Duplicate slug "{event.slug}"')
return True
if event.id == new_event.id:
log.error(f'Duplicate event id "{event.id}"')
return True
if event.guid == new_event.guid:
log.error(f'Duplicate guid "{event.guid}"')
return True
else:
return False

Expand Down
Loading