diff --git a/confarchive/model.py b/confarchive/model.py index 55ba9ea..2471eb3 100644 --- a/confarchive/model.py +++ b/confarchive/model.py @@ -3,6 +3,7 @@ import sqlalchemy import sqlalchemy.orm.decl_api from sqlalchemy import func +from sqlalchemy.dialects import postgresql from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.orderinglist import ordering_list @@ -39,6 +40,32 @@ class Conference(TimeStampedModel): acronym = Column(String) url = Column(String) schedule_xml_url = Column(String) + short_name = Column(String, unique=True) + + people_detail = relationship( + "ConferencePerson", lazy="dynamic", back_populates="conference" + ) + people = association_proxy("people_detail", "person") + + events = relationship( + "Event", + order_by="Event.event_date", + back_populates="conference", + lazy="dynamic", + ) + + +class ConferencePerson(Base): + __tablename__ = "conference_person" + conference_id = Column(Integer, ForeignKey("conference.id"), primary_key=True) + person_id = Column(Integer, ForeignKey("person.id"), primary_key=True) + named_as = Column(String) + bio = Column(String) + slug = Column(String) + url = Column(String) + + person = relationship("Person", back_populates="conferences_association") + conference = relationship("Conference", back_populates="people_detail") class Event(TimeStampedModel): @@ -48,7 +75,7 @@ class Event(TimeStampedModel): id = Column(Integer, primary_key=True) conference_id = Column(Integer, ForeignKey("conference.id"), nullable=False) event_date = Column(DateTime) - day = Column(Integer) + # day = Column(Integer) guid = Column(String) start = Column(String) duration = Column(String) @@ -56,22 +83,23 @@ class Event(TimeStampedModel): track = Column(String) slug = Column(String) title = Column(String, nullable=False) + abstract = Column(String) description = Column(String) event_type = Column(String) url = Column(String) - conference = relationship("Conference", backref="events") + conference = relationship("Conference", back_populates="events") - people_association = relationship( + people_detail = relationship( "EventPerson", order_by="EventPerson.position", back_populates="event", collection_class=ordering_list("position"), ) people = association_proxy( - "people_association", + "people_detail", "person", - creator=lambda person: EventPerson(person=person), + creator=lambda i: EventPerson(person=i[0], named_as=i[1]), ) @@ -83,9 +111,39 @@ class Person(TimeStampedModel): name = Column(String) wikidata_qid = Column(String) gender = Column(String) + wikidata_photo = Column(postgresql.ARRAY(String)) - events_association = relationship("EventPerson", back_populates="person") - events = association_proxy("event_association", "event") + events_association = relationship( + "EventPerson", + back_populates="person", + lazy="dynamic", + ) + events = association_proxy("events_association", "event") + + conferences_association = relationship("ConferencePerson", back_populates="person") + conferences = association_proxy("conference_association", "conference") + + # photos = relationship("PersonPhoto", back_populates="person") + + def events_by_time(self): + q = ( + session.query(Event) + .join(EventPerson) + .filter(EventPerson.person == self) + .order_by(Event.event_date.desc()) + ) + + return q + + +# class PersonPhoto(TimeStampedModel): +# """Person photo.""" +# +# __tablename__ = "person_photo" +# person_id = Column(Integer, ForeignKey("person.id"), primary_key=True) +# source_filename = Column(String) +# +# person = relationship("Person", back_populates="photos") class EventPerson(Base): @@ -95,7 +153,6 @@ class EventPerson(Base): event_id = Column(Integer, ForeignKey("event.id"), primary_key=True) person_id = Column(Integer, ForeignKey("person.id"), primary_key=True) position = Column(Integer, nullable=False) - named_as = Column(String) person = relationship("Person", back_populates="events_association") - event = relationship("Event", back_populates="people_association") + event = relationship("Event", back_populates="people_detail") diff --git a/load_conference.py b/load_conference.py index 44bd604..77a16df 100755 --- a/load_conference.py +++ b/load_conference.py @@ -1,12 +1,15 @@ #!/usr/bin/python3 import datetime +import os +import sys import lxml.etree from confarchive import database, model DB_URL = "postgresql:///confarchive" +schedules_loc = "/home/edward/src/2022/conference-gender-mix/schedules" database.init_db(DB_URL) @@ -38,7 +41,7 @@ def read_field(root: Element, field: str) -> str | None: def read_date_field(root: Element, field: str) -> datetime.date | None: """Read date from a field.""" value = read_field(root, field) - return parse_isodate(value) if value is not None else None + return parse_isodate(value) if value else None def read_required_field(root: Element, field: str) -> str: @@ -58,55 +61,99 @@ def conference_obj(root: Element) -> model.Conference: e = root.find(".//conference") assert e is not None + start = read_date_field(e, "start") or read_date_field(e, "start_date") + days_str = read_field(e, "days") + days = int(days_str) if days_str else None + assert start + end: datetime.date | None + if days: + end = start + datetime.timedelta(days=days - 1) + else: + end = read_date_field(e, "end") or read_date_field(e, "end_date") + assert end + + if not start: + print(lxml.etree.tostring(e, encoding=str)) + sys.exit(1) + + assert start and end and end >= start + return model.Conference( title=read_required_field(e, "title"), - start=read_date_field(e, "start"), - end=read_date_field(e, "end"), + start=start, + end=end, timezone=read_field(e, "time_zone_name"), ) def build_event_object( - e: Element, person_lookup: dict[int, model.Person] + e: Element, person_lookup: dict[str, model.Person] ) -> model.Event | None: """Build an event object.""" - title = read_required_field(e, "title") - if not_a_talk(title): - return None - + title = read_field(e, "title") + guid = e.get("guid") room = read_field(e, "room") slug = read_field(e, "slug") description = read_field(e, "description") event_type = read_field(e, "type") url = read_field(e, "url") - persons_element = e.find(".//persons") - if persons_element is None: + if title is None: + print("no title") + assert description is None and event_type is None + return None + + persons = e.find(".//persons") + if persons is None or len(persons) == 0: + persons = e.findall(".//person") + if persons is None or len(persons) == 0: return None people = [] - for p in persons_element: - id_str = p.get("id") - assert id_str is not None - people.append(person_lookup[int(id_str)]) + seen_person = set() + print("persons:", len(persons)) + for p in persons: + name = p.text + print("peron:", name) + if name is None: + print("no name") + if name in seen_person: + print("seen already:", name) + if name is None or name in seen_person: + continue + seen_person.add(name) + people.append(model.EventPerson(person=person_lookup[name])) - print(title, people) + if not people: + print("no people") + return None return model.Event( + guid=guid, title=title, room=room, slug=slug, description=description, event_type=event_type, url=url, - people=people, + people_detail=people, ) +def schedule_has_person_ids(root: Element) -> bool: + """People listed in schedule have ids.""" + person = root.find(".//person") + assert person is not None + person_id = person.get("id") + return person_id is not None + + def get_all_people(root: Element) -> list[tuple[int, str]]: people: dict[int, str] = {} for person in root.findall(".//person"): assert person.text person_id_str = person.get("id") + if not person_id_str: + print(lxml.etree.tostring(person, encoding=str)) assert person_id_str person_id = int(person_id_str) existing = people.get(person_id) @@ -118,36 +165,111 @@ def get_all_people(root: Element) -> list[tuple[int, str]]: return sorted(people.items()) -def load(filename: str) -> None: +def get_people_names(root: Element) -> set[str]: + return { + normalize_name(person.text) + for person in root.findall(".//person") + if person.text + } + + +def normalize_name(n: str) -> str: + """Normalize name.""" + return " ".join(n.split()).strip() + + +def find_existing_person(name: str) -> model.Person | None: + # print("searching for:", name) + person = model.Person.query.filter(model.Person.name.ilike(name)).one_or_none() + assert person is None or isinstance(person, model.Person) + if person: + return person + + person = model.ConferencePerson.query.filter( + model.ConferencePerson.named_as.ilike(name) + ).one_or_none() + assert person is None or isinstance(person, model.Person) + if person: + return person + + +def load(filename: str, short_name: str) -> None: """Load conference schedule.""" + start = open(filename).read(15) + if start == "BEGIN:VCALENDAR" or start.startswith("{"): + return None + root = lxml.etree.parse(filename).getroot() - conf = conference_obj(root) - database.session.add(conf) + + conf = model.Conference.query.filter_by(short_name=short_name).one_or_none() + + if conf: + assert conf.events.count() == 0 + else: + conf = conference_obj(root) + assert model.Conference.query.filter_by(title=conf.title).count() == 0 + database.session.add(conf) + + print((conf.short_name, conf.title)) event_count = 0 - people = get_all_people(root) + people_names = get_people_names(root) person_lookup = {} - for person_id, name in people: - person = model.Person.query.filter_by(name=name).first() + for name in people_names: + cp = model.ConferencePerson.query.filter_by( + conference=conf, named_as=name + ).one_or_none() + + if cp and cp.person.events_association.count() == 0: + person = cp.person + for cp2 in person.conferences_association: + database.session.delete(cp2) + database.session.delete(cp.person) + database.session.commit() + + person = find_existing_person(name) if not person: person = model.Person(name=name) database.session.add(person) - person_lookup[person_id] = person + person_lookup[name] = person + + for name, person in person_lookup.items(): + if model.ConferencePerson.query.filter_by( + conference=conf, person=person + ).one_or_none(): + continue + + conf_person = model.ConferencePerson( + conference=conf, person=person, named_as=name + ) + database.session.add(conf_person) for day in root.findall(".//day"): - for room in root.findall(".//room"): - for event_element in root.findall(".//event"): - event = build_event_object(event_element, person_lookup) - if not event: - continue - event.conference = conf - print() - database.session.add(event) - event_count += 1 - if event_count > 10: - return None + day_index_str = day.get("index") + # assert day_index_str is not None + # day_index = int(day_index_str) + print("day", day_index_str) + for event_element in day.findall(".//event"): + title = read_field(event_element, "title") + event = build_event_object(event_element, person_lookup) + if not event: + print(f"skip event: {title}") + continue + event.conference = conf + # event.day = day_index + database.session.add(event) + event_count += 1 + + if event_count > 1: + database.session.commit() -load("/home/edward/src/2022/conference-gender-mix/schedules/debconf22") - -database.session.commit() +for f in os.scandir(schedules_loc): + if f.is_dir(): + continue + if f.name in {"datenspuren_2019"}: + continue + if not f.name.startswith("capitole_du_libre"): + continue + print(f.name) + load(f.path, f.name) diff --git a/main.py b/main.py index 1a16cb8..798636a 100755 --- a/main.py +++ b/main.py @@ -1,8 +1,19 @@ #!/usr/bin/python3 -import flask +import hashlib +import json +import os +import time +import typing +from typing import cast -from confarchive import database +import flask +import requests +import sqlalchemy +from sqlalchemy import func, update +from werkzeug.wrappers import Response + +from confarchive import database, model app = flask.Flask(__name__) app.debug = True @@ -10,12 +21,260 @@ app.debug = True app.config.from_object("config.default") database.init_app(app) +wikidata_api = "https://www.wikidata.org/w/api.php" + + +def md5sum(s: str) -> str: + return hashlib.md5(s.encode("utf-8")).hexdigest() + + +def wikidata_search(q: str) -> list[dict[str, typing.Any]]: + q += " haswbstatement:P31=Q5" + q_md5 = md5sum(q) + + cache_filename = os.path.join("cache", q_md5 + ".json") + + if os.path.exists(cache_filename): + data = json.load(open(cache_filename)) + else: + params: dict[str, str | int] = { + "action": "query", + "list": "search", + "format": "json", + "formatversion": 2, + "srsearch": q, + "srlimit": "max", + } + r = requests.get(wikidata_api, params=params) + open(cache_filename, "w").write(r.text) + data = r.json() + time.sleep(1) + + return cast(dict[str, typing.Any], data["query"]["search"]) + + +def wikidata_get_item(qid: str) -> typing.Any: + cache_filename = os.path.join("items", qid + ".json") + if os.path.exists(cache_filename): + item = json.load(open(cache_filename)) + else: + params: dict[str, str | int] = { + "action": "wbgetentities", + "ids": qid, + "format": "json", + "formatversion": 2, + } + r = requests.get(wikidata_api, params=params) + item = r.json()["entities"][qid] + with open(cache_filename, "w") as f: + json.dump(item, f, indent=2) + time.sleep(0.1) + return item + + +def top_speakers() -> sqlalchemy.orm.query.Query: + q = ( + database.session.query(model.Person, func.count()) + .join(model.ConferencePerson) + .group_by(model.Person) + .order_by(func.count().desc()) + .having(func.count() > 5) + ) + return q + + +def top_speakers2() -> sqlalchemy.orm.query.Query: + q = ( + database.session.query(model.Person, func.count()) + .join(model.ConferencePerson) + .filter(model.Person.name.like("% %")) + .group_by(model.Person) + .order_by(func.count().desc()) + .having(func.count() > 2) + ) + # .order_by(func.length(model.Person.name).desc()) + return q + + +def top_events() -> sqlalchemy.orm.query.Query: + q = ( + database.session.query(model.Event.title, func.count()) + .group_by(model.Event.title) + .order_by(func.count().desc()) + .having(func.count() > 5) + ) + return q + + +@app.route("/person/", methods=["GET", "POST"]) +def person(person_id: int) -> str | Response: + item = model.Person.query.get(person_id) + if flask.request.method == "POST": + item.wikidata_qid = flask.request.form["wikidata_qid"] or None + item.name = flask.request.form["name"] + database.session.commit() + assert flask.request.endpoint + return flask.redirect( + flask.url_for(flask.request.endpoint, person_id=person_id) + ) + + return flask.render_template("person.html", item=item, Event=model.Event) + + +@app.route("/event/") +def event_page(event_id: int) -> str: + item = model.Event.query.get(event_id) + return flask.render_template("event.html", item=item) + + +@app.route("/conference/") +def conference_page(short_name: str) -> str: + item = model.Conference.query.filter_by(short_name=short_name).one_or_none() + if item is None: + flask.abort(404) + return flask.render_template("conference.html", item=item) + + +@app.route("/people") +def search_people() -> str: + search_for = flask.request.args["q"] + assert search_for + search_for = search_for.strip() + q = model.Person.query.filter(model.Person.name.ilike(f"%{search_for}%")).order_by( + model.Person.name + ) + return flask.render_template("search_people.html", q=q, search_for=search_for) + + +@app.route("/merge", methods=["GET", "POST"]) +def merge() -> str | Response: + if flask.request.method == "POST": + search_for = flask.request.form["q"] + + item_ids_str = flask.request.form.getlist("person_id") + item_ids: list[int] = [int(i) for i in item_ids_str] + + merge_to_id = min(item_ids) + other_ids = [i for i in item_ids if i != merge_to_id] + + print(other_ids, "->", merge_to_id) + + with database.session.begin(): + print("update ConferencePerson") + database.session.execute( + update(model.ConferencePerson) + .where(model.ConferencePerson.person_id.in_(other_ids)) + .values(person_id=merge_to_id) + ) + + print("update EventPerson") + database.session.execute( + update(model.EventPerson) + .where(model.EventPerson.person_id.in_(other_ids)) + .values(person_id=merge_to_id) + ) + + print("delete people") + for person_id in other_ids: + item = model.Person.query.get(person_id) + database.session.delete(item) + + endpoint = flask.request.endpoint + assert endpoint + return flask.redirect(flask.url_for(endpoint, q=search_for)) + + else: + search_for = flask.request.args["q"] + + assert search_for + search_for = search_for.strip() + q = model.Person.query.filter(model.Person.name.ilike(f"%{search_for}%")).order_by( + model.Person.name + ) + return flask.render_template("merge_people.html", q=q, search_for=search_for) + + +@app.route("/events") +def events_page() -> str: + search_for = flask.request.args.get("q") + if not search_for: + return flask.render_template("top_events.html", top_events=top_events()) + + q = model.Event.query.filter(model.Event.title.ilike(f"%{search_for}%")).order_by( + model.Event.title + ) + return flask.render_template("search_events.html", q=q, search_for=search_for) + @app.route("/") def index() -> str: """Start page.""" - return flask.render_template("index.html") + if False: + q = ( + model.Conference.query.order_by(model.Conference.start.desc()) + .add_columns( + func.count(model.Event.id), func.count(model.ConferencePerson.person_id) + ) + .group_by(model.Conference) + ) + + q = model.Conference.query.order_by(model.Conference.start.desc()) + + count = { + "conference": model.Conference.query.count(), + "event": model.Event.query.count(), + "person": model.Person.query.count(), + } + + return flask.render_template("index.html", items=q, count=count) + + +@app.route("/speakers") +def top_speakers_page() -> str: + """Top speakers page.""" + return flask.render_template("top_speakers.html", top_speakers=top_speakers()) + + +@app.route("/wikidata") +def link_to_wikidata() -> str: + items = [] + for person, num in top_speakers2(): + if person.wikidata_qid: + continue + search_hits = wikidata_search(f'"{person.name}"') + if not search_hits: + continue + + if len(search_hits) > 10: + continue + + hits = [] + + for search_hit in search_hits: + qid = search_hit["title"] + item = wikidata_get_item(qid) + if "en" in item["labels"]: + label = item["labels"]["en"]["value"] + else: + label = "[no english label]" + + if "en" in item["descriptions"]: + description = item["descriptions"]["en"]["value"] + else: + description = "[no english description]" + + hits.append( + { + "qid": qid, + "label": label, + "description": description, + } + ) + + items.append((person, num, hits)) + + return flask.render_template("wikidata.html", items=items) if __name__ == "__main__": - app.run(host="0.0.0.0") + app.run(host="0.0.0.0", port=5002) diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5f2d87e --- /dev/null +++ b/templates/base.html @@ -0,0 +1,25 @@ + + + + + + +{% block title %}Xanadu{% endblock %} + + + +{% block style %} +{% endblock %} + + + +{% block content %} +{% endblock %} + + + +{% block scripts %} +{% endblock %} + + + diff --git a/templates/conference.html b/templates/conference.html new file mode 100644 index 0000000..8b355fc --- /dev/null +++ b/templates/conference.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}{{ item.title }}{% endblock %} + + {% block content %} +
+
+

{{ item.title }}

+

home

+ +
    +
  • start: {{ item.start }}
  • +
  • end: {{ item.end }}
  • + {% if days %} +
  • days: {{ item.days }}
  • + {% endif %} +
  • short name: {{ item.short_name }}
  • +
  • country: {{ item.country or "n/a" }}
  • +
+ +

Talks

+ +

{{ item.events.count() }} talks

+ {% for event in item.events %} + +
+
+
+ {{ event.title }} +
+
+ {% if event.event_date %} + {{ event.event_date.strftime("%d %b %Y at %H:%M") }} + {% else %} + event date missing + {% endif %} +
+

+ {% if event.url %} + talk on conference website + {% endif %} + + {% if event.abstract %} +

+ {% if "<" in event.abstract %} + {{ event.abstract | safe }} + {% else %} + {{ event.abstract }} + {% endif %} +

+ {% endif %} + + {% if event.description %} +

+ {% if "<" in event.description %} + {{ event.description | safe }} + {% else %} + {{ event.description }} + {% endif %} +

+ {% endif %} + +

+ Speakers: + {% for p in event.people %} + {{ p.name }} + {% endfor %} +

+
+
+ + {% endfor %} + +
+
+{% endblock %} diff --git a/templates/event.html b/templates/event.html new file mode 100644 index 0000000..1aa828e --- /dev/null +++ b/templates/event.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}{{ item.name }}{% endblock %} + + {% block content %} +
+
+

{{ item.title }}

+

home

+ + +
+
+
+ {% if item.url %} + {{ item.title }} + {% else %} + {{ item.title }} + {% endif %} +
+
+ {{ item.conference.title }} +
+ + {% if item.abstract %} +

+ {% if "<" in item.abstract %} + {{ item.abstract | safe }} + {% else %} + {{ item.abstract }} + {% endif %} +

+ {% endif %} + + +

+ {% if "<" in item.description %} + {{ item.description | safe }} + {% else %} + {{ item.description }} + {% endif %} +

+

+ Speakers: + {% for p in item.people %} + {{ p.name }} + {% endfor %} +

+
+
+ + +
+
+{% endblock %} diff --git a/templates/index.html b/templates/index.html index d6000ca..e994523 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,10 +1,50 @@ - - - - - - +{% extends "base.html" %} + +{% block title %}Conference archive{% endblock %} + + {% block content %} +
+
+

Conference archive

+ +
+
+ + +
+ +
+ + +
    +
  • {{ "{:,d}".format(count.conference) }} conferences
  • +
  • {{ "{:,d}".format(count.event) }} talks - + most common titles +
  • +
  • + {{ "{:,d}".format(count.person) }} speakers + top speakers +
  • +
+ + + + {% for item in items %} + + + + + + + + {% endfor %} + +
+ {{ item.title }} + {{ item.start.strftime("%d %b %Y") }}{{ (item.end - item.start).days + 1 }} days{{ item.events.count() }} talks{{ item.people_detail.count() }} speakers
+ +
+
+{% endblock %} + - - - \ No newline at end of file diff --git a/templates/merge_people.html b/templates/merge_people.html new file mode 100644 index 0000000..54e2685 --- /dev/null +++ b/templates/merge_people.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}Conference archive{% endblock %} + + {% block content %} +
+
+

Conference archive

+ +
+
+ + +
+ +
+ +

Found {{ q.count() }} people matching '{{ search_for }}'

+ +
+ + + + {% for item in q %} +
+ + +
+ + {% endfor %} + + +
+
+
+{% endblock %} + + diff --git a/templates/person.html b/templates/person.html new file mode 100644 index 0000000..50c6452 --- /dev/null +++ b/templates/person.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block title %}{{ item.name }}{% endblock %} + + {% block content %} +
+
+

{{ item.name }}

+

home

+ + +

Conferences

+ {% for apperance in item.conferences_association %} + {% set conf = apperance.conference %} + +
+
+
{{ conf.id }}: {{ conf.title }}
+

+ {% if apperance.bio %}{{ apperance.bio | safe }}{% else %}No speaker biography.{% endif %} +

+
+
+ {% endfor %} + + + {% set search_for = '"' + item.name + '" ' + " haswbstatement:P31=Q5" %} +

Search for {{ item.name }} on Wikidata

+ +
+
+ + +
+
+ + +
+ +
+ +

Talks

+

Has {{ item.events_association.count() }} events

+ {% for event in item.events_by_time() %} +
+
+
+ {{ event.title }} +
+
+ {{ event.conference.title }} + — + {% if event.event_date %} + {{ event.event_date.strftime("%d %b %Y") }} + {% else %} + event date missing + {% endif %} + +
+

+ {% if event.url %} + {{ event.title }} on conference website + {% endif %} + + {% if event.abstract %} +

+ {% if "<" in event.abstract %} + {{ event.abstract | safe }} + {% else %} + {{ event.abstract }} + {% endif %} +

+ {% endif %} + + {% if event.description %} +

+ {% if "<" in event.description %} + {{ event.description | safe }} + {% else %} + {{ event.description }} + {% endif %} +

+ {% endif %} +

+ {% for p in event.people %} + {% if p.id != item.id %} + {{ p.name }} + {% endif %} + {% endfor %} +

+
+
+ + {% endfor %} + +
+
+{% endblock %} diff --git a/templates/search_events.html b/templates/search_events.html new file mode 100644 index 0000000..fd18e9a --- /dev/null +++ b/templates/search_events.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}Conference archive{% endblock %} + + {% block content %} +
+
+

Conference archive

+ +
+
+ + +
+ +
+ +

Found {{ q.count() }} events matching '{{ search_for }}'

+ +
    + {% for item in q %} +
  • + {{ item.title }} + — + {{ item.conference.title }} + — + {% for p in item.people %} + {{ p.name }} + {% endfor %} +
  • + {% endfor %} +
+
+
+{% endblock %} + + diff --git a/templates/search_people.html b/templates/search_people.html new file mode 100644 index 0000000..bb1572b --- /dev/null +++ b/templates/search_people.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}Conference archive{% endblock %} + + {% block content %} +
+
+

Conference archive

+

home

+ +
+
+ + +
+ +
+ +

+ Found {{ q.count() }} people matching '{{ search_for }}' + + merge +

+ + +
+
+{% endblock %} + + diff --git a/templates/top_events.html b/templates/top_events.html new file mode 100644 index 0000000..5bc51f7 --- /dev/null +++ b/templates/top_events.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}Conference archive{% endblock %} + + {% block content %} +
+
+

Conference archive

+

home

+ +

Top events

+ +
+
+ + +
+ +
+ +
    + {% for event_title, count in top_events %} +
  • + {{ event_title }} + ({{ count }}) +
  • + {% endfor %} +
+ + + +
+
+{% endblock %} + + diff --git a/templates/top_speakers.html b/templates/top_speakers.html new file mode 100644 index 0000000..8b5a71b --- /dev/null +++ b/templates/top_speakers.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}Conference archive{% endblock %} + + {% block content %} +
+
+

Conference archive

+ +
+
+ + +
+ +
+ +

Top speakers

+ + +
+
+{% endblock %} + + diff --git a/templates/wikidata.html b/templates/wikidata.html new file mode 100644 index 0000000..347f42a --- /dev/null +++ b/templates/wikidata.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}Conference archive{% endblock %} + + {% block content %} +
+
+

Conference archive

+ + {% for person, count, wikidata_hits in items %} +
+

{{ person.name }} ({{ count }})

+
    + {% for hit in wikidata_hits %} +
  • + {{ hit.qid }} + {{ hit.label }} — {{ hit.description }} +
  • + {% endfor %} +
+ +
+ {% endfor %} + +
+
+{% endblock %} + +{% block style %} + +{% endblock %}