Skip to content
Snippets Groups Projects
offices.py 8.77 KiB
Newer Older
#!/usr/bin/env python3
Guilhem Saurel's avatar
Guilhem Saurel committed
"""Utils to manage Gepetto offices"""
Guilhem Saurel's avatar
Guilhem Saurel committed

Guilhem Saurel's avatar
Guilhem Saurel committed
import logging
from argparse import ArgumentParser
Guilhem Saurel's avatar
Guilhem Saurel committed
from collections import defaultdict
from datetime import date
from json import dumps, loads
Guilhem Saurel's avatar
Guilhem Saurel committed
from pathlib import Path
Guilhem Saurel's avatar
Guilhem Saurel committed
from typing import NamedTuple

from ldap3 import Connection
from wand.color import Color
from wand.drawing import Drawing
from wand.image import Image

Guilhem Saurel's avatar
Guilhem Saurel committed
# Cache LDAP data
Guilhem Saurel's avatar
Guilhem Saurel committed
CACHE = Path("data/offices-ldap.json")
Guilhem Saurel's avatar
Guilhem Saurel committed
# Drawings constants
Guilhem Saurel's avatar
Guilhem Saurel committed
LOGO = "data/logo-low-black.png"
Guilhem Saurel's avatar
Guilhem Saurel committed
DPCM = 300 / 2.54  # dot per cm @300DPI
WIDTH, HEIGHT = int(6 * DPCM), int(3 * DPCM)  # door labels are 6cm x 3cm
Guilhem Saurel's avatar
Guilhem Saurel committed
NOT_OFFICES = ["Exterieur", "BSalleGerardBauzil"]
Guilhem Saurel's avatar
Guilhem Saurel committed
BAT_B = "data/rdc.png"
Guilhem Saurel's avatar
Guilhem Saurel committed
MAP_POSITIONS = [
Guilhem Saurel's avatar
Guilhem Saurel committed
    ("B20", 460, 50, 650, 228, -350),
    ("B19", 460, 233, 650, 412, -350),
    ("B18", 460, 420, 650, 598, -350),
    ("B17", 460, 608, 650, 785, -350),
    ("B16", 460, 793, 650, 966, -350),
    ("B10", 1410, 450, 1670, 691, 400),
    ("B08", 1410, 700, 1670, 925, 400),
    ("B06", 1410, 932, 1670, 1161, 400),
    ("B04", 1410, 1453, 1670, 1647, 400),
    ("B03", 1410, 1656, 1670, 1834, 400),
    ("B01", 1410, 2021, 1670, 2202, 400),
Guilhem Saurel's avatar
Guilhem Saurel committed
]
Guilhem Saurel's avatar
Guilhem Saurel committed


class Gepettist(NamedTuple):
    """A Gepettist has a SurName and a GivenName."""
Guilhem Saurel's avatar
Guilhem Saurel committed

Guilhem Saurel's avatar
Guilhem Saurel committed
    sn: str
    gn: str

    def __str__(self):
Guilhem Saurel's avatar
Guilhem Saurel committed
        return f"{self.gn} {self.sn}"
Guilhem Saurel's avatar
Guilhem Saurel committed


class Offices:
    """A dict with rooms as key and set of Gepettists as values, defaulting to empty set."""
Guilhem Saurel's avatar
Guilhem Saurel committed

    def __init__(self, **offices):
        self.data = defaultdict(set)
        self.data.update(offices)

    def __str__(self):
Guilhem Saurel's avatar
Guilhem Saurel committed
        return "\n".join(
            f'{room:5}: {", ".join(str(m) for m in members)}'
            for room, members in self.sorted().items()
        )

    def __getitem__(self, key):
        return self.data[key]

Guilhem Saurel's avatar
Guilhem Saurel committed
    def __setitem__(self, key, value):
        self.data[key] = value

    def __iter__(self):
        yield from self.data

    def items(self):
        return self.data.items()

    def sorted(self):
Guilhem Saurel's avatar
Guilhem Saurel committed
        return {
            o: sorted(self.data[o])
            for o in sorted(self.data)
            if o != "Exterieur" and self.data[o]
        }

    def dumps(self):
        """dump a sorted dict of offices with sorted lists of members as a JSON string"""
        return dumps(self.sorted(), ensure_ascii=False, indent=2, sort_keys=True)

    @staticmethod
    def loads(s):
        """constructor from a JSON string"""
Guilhem Saurel's avatar
Guilhem Saurel committed
        return Offices(
            **{
                room: set(Gepettist(*m) for m in members)
                for room, members in loads(s).items()
            }
        )
Guilhem Saurel's avatar
Guilhem Saurel committed
# Stuff that is wrong in LDAP… We should fix that there
WRONG_OFFICE = {
    "B04": {
        ("Vincent", "Bonnet"),
    },
Guilhem Saurel's avatar
Guilhem Saurel committed
    "B10": {
        ("Guilhem", "Saurel"),
    },
    "Exterieur": {
        ("Ariane", "Lalles"),
    },
Guilhem Saurel's avatar
Guilhem Saurel committed
}
WRONG_OFFICE = {
    k: {Gepettist(sn, gn) for (gn, sn) in v} for k, v in WRONG_OFFICE.items()
Guilhem Saurel's avatar
Guilhem Saurel committed
}
Guilhem Saurel's avatar
Guilhem Saurel committed
# Fix unicode from LDAP data…
Guilhem Saurel's avatar
Guilhem Saurel committed
ALIAS = {
Guilhem Saurel's avatar
Guilhem Saurel committed
    "B08": [
Guilhem Saurel's avatar
Guilhem Saurel committed
        (
Guilhem Saurel's avatar
Guilhem Saurel committed
            {Gepettist("Leziart", "Pierre-Alexandre")},
            {Gepettist("Léziart", "Pierre-Alexandre")},
        )
Guilhem Saurel's avatar
Guilhem Saurel committed
    ],
Guilhem Saurel's avatar
Guilhem Saurel committed
    "B17": [({Gepettist("Taix", "Michel")}, {Gepettist("Taïx", "Michel")})],
    "B19": [({Gepettist("Soueres", "Philippe")}, {Gepettist("Souères", "Philippe")})],
Guilhem Saurel's avatar
Guilhem Saurel committed
}
Guilhem Saurel's avatar
Guilhem Saurel committed
def door_label(members, logo=True):
Guilhem Saurel's avatar
Guilhem Saurel committed
    """Generate the label for one office."""
Guilhem Saurel's avatar
Guilhem Saurel committed
    if not members:
        return
Guilhem Saurel's avatar
Guilhem Saurel committed
    with Image(
        width=WIDTH, height=HEIGHT, background=Color("white")
    ) as img, Drawing() as draw:
Guilhem Saurel's avatar
Guilhem Saurel committed
        if logo:
Guilhem Saurel's avatar
Guilhem Saurel committed
            with Image(filename=LOGO) as li:
                li.transform(resize=f"{WIDTH}x{HEIGHT}")
                draw.composite("over", 200, 0, li.width, li.height, li)
        if len(members) > 2 or not logo:
            draw.font_size = 60
        # elif len(members) == 3:
        # draw.font_size = 75
Guilhem Saurel's avatar
Guilhem Saurel committed
        draw.text_alignment = "center"
Guilhem Saurel's avatar
Guilhem Saurel committed
        height = HEIGHT - len(members) * draw.font_size
Guilhem Saurel's avatar
Guilhem Saurel committed
        draw.text(
            int(WIDTH / 2),
            int(height / 2) + 65,
            "\n".join(str(m) for m in sorted(members)),
        )
Guilhem Saurel's avatar
Guilhem Saurel committed
        draw(img)
        return img.clone()


Guilhem Saurel's avatar
Guilhem Saurel committed
def office_number(office):
    c = int(DPCM * 1.5)
    with Image(width=c, height=c, background=Color("white")) as img, Drawing() as draw:
        draw.font_size = 90
        draw.text_alignment = "center"
        draw.text(int(c / 2), int(c / 2), office)
        draw(img)
        return img.clone()


def offices_ldap():
    """Get a dict of Gepettists in their respective office from LDAP."""
Guilhem Saurel's avatar
Guilhem Saurel committed
    conn = Connection("ldap.laas.fr", auto_bind=True)
    conn.search(
        "dc=laas,dc=fr",
        "(laas-mainGroup=gepetto)",
        attributes=["sn", "givenName", "roomNumber", "st"],
    )
    offices = Offices()
Guilhem Saurel's avatar
Guilhem Saurel committed
    for entry in conn.entries:
Guilhem Saurel's avatar
Guilhem Saurel committed
        room, gn, sn, st = (
            str(entry.roomNumber),
            str(entry.givenName),
            str(entry.sn),
            str(entry.st),
        )
        if (
            st not in ["JAMAIS", "NON-PERTINENT"]
            and date(*(int(i) for i in reversed(st.split("/")))) < date.today()
        ):
Guilhem Saurel's avatar
Guilhem Saurel committed
            continue  # filter out alumni
Guilhem Saurel's avatar
Guilhem Saurel committed
        if room == "[]":
Guilhem Saurel's avatar
Guilhem Saurel committed
            logging.warning(f"Pas de bureau pour {gn} {sn}")
Guilhem Saurel's avatar
Guilhem Saurel committed
            continue  # filter out the Sans-Bureaux-Fixes
        offices[room].add(Gepettist(sn, gn))
    return offices


Guilhem Saurel's avatar
Guilhem Saurel committed
def fix_wrong_offices(offices):
Guilhem Saurel's avatar
Guilhem Saurel committed
    """Fix the dict of Gepettists in their respective office from embedded infos."""
Guilhem Saurel's avatar
Guilhem Saurel committed
    for woffice, wmembers in WRONG_OFFICE.items():  # Patch wrong stuff from LDAP
        offices[woffice] |= wmembers  # Add members to their rightfull office
        for wrong_office in offices:
            if wrong_office != woffice:
                offices[wrong_office] -= wmembers  # remove them from the other offices
    for office, aliases in ALIAS.items():
        for before, after in aliases:
            offices[office] = offices[office] - before | after
    return offices
def labels(offices):
Guilhem Saurel's avatar
Guilhem Saurel committed
    """Generate an A4 papier with labels for the doors of Gepetto offices."""
Guilhem Saurel's avatar
Guilhem Saurel committed
    with Image(
        width=int(21 * DPCM), height=int(29.7 * DPCM)
    ) as page, Drawing() as draw:
        for i, (office, members) in enumerate(offices.items()):
            if not members or office in NOT_OFFICES:
Guilhem Saurel's avatar
Guilhem Saurel committed
                continue
Guilhem Saurel's avatar
Guilhem Saurel committed
            label = door_label(members)
            row, col = divmod(i, 3)
            row *= HEIGHT + DPCM
            col *= WIDTH + DPCM * 0.5
Guilhem Saurel's avatar
Guilhem Saurel committed
            draw.composite(
                "over",
                int(col + DPCM * 0.75),
                int(row + DPCM),
                label.width,
                label.height,
                label,
            )
Guilhem Saurel's avatar
Guilhem Saurel committed
        draw(page)
Guilhem Saurel's avatar
Guilhem Saurel committed
        page.save(filename="labels.png")
Guilhem Saurel's avatar
Guilhem Saurel committed
def maps(offices, fixed):
    """Generate a map with labels"""
    with Image(filename=BAT_B) as page, Drawing() as draw:
Guilhem Saurel's avatar
Guilhem Saurel committed
        for office, x1, y1, x2, y2, shift in MAP_POSITIONS:
            for i, img in enumerate(
                (office_number(office), door_label(offices[office], logo=False)),
            ):
                if img:
                    width = img.width / 2
                    height = img.height / 2
                    x = (x1 + x2 - width) / 2 + shift * i
                    y = (y1 + y2 - height) / 2
                    draw.composite("over", x, y, width, height, img)
                else:
                    logging.warning(f"no label for {office}")
Guilhem Saurel's avatar
Guilhem Saurel committed
        draw(page)
Guilhem Saurel's avatar
Guilhem Saurel committed
        page.save(filename="generated_map%s.png" % ("_fixed" if fixed else ""))
Guilhem Saurel's avatar
Guilhem Saurel committed
if __name__ == "__main__":
Guilhem Saurel's avatar
Guilhem Saurel committed
    parser = ArgumentParser(description=__doc__)
Guilhem Saurel's avatar
Guilhem Saurel committed
    parser.add_argument("--update", action="store_true", help="update data from ldap")
    parser.add_argument(
        "--fixed", action="store_true", help="fix LDAP data from embeded infos"
    )
    parser.add_argument("--show", action="store_true", help="show data")
    parser.add_argument("--labels", action="store_true", help="generate door labels")
    parser.add_argument("--map", action="store_true", help="generate offices map")
    parser.add_argument("-v", "--verbose", action="count", default=0)
Guilhem Saurel's avatar
Guilhem Saurel committed

    args = parser.parse_args()
    logging.basicConfig(level=50 - 10 * args.verbose)

    # Collect and fix data
    if args.update or not CACHE.exists():
Guilhem Saurel's avatar
Guilhem Saurel committed
        logging.info(" updating team members from LDAP")
Guilhem Saurel's avatar
Guilhem Saurel committed
        offices = offices_ldap()
Guilhem Saurel's avatar
Guilhem Saurel committed
        with CACHE.open("w") as f:
Guilhem Saurel's avatar
Guilhem Saurel committed
            f.write(offices.dumps())
    else:
Guilhem Saurel's avatar
Guilhem Saurel committed
        logging.info(" using cached team members")
Guilhem Saurel's avatar
Guilhem Saurel committed
        with CACHE.open() as f:
            offices = Offices.loads(f.read())
    if args.fixed:
Guilhem Saurel's avatar
Guilhem Saurel committed
        logging.info(" fixing data")
Guilhem Saurel's avatar
Guilhem Saurel committed
        offices = fix_wrong_offices(offices)

    # Use collected data
    if args.show:
Guilhem Saurel's avatar
Guilhem Saurel committed
        logging.info(" showing data")
Guilhem Saurel's avatar
Guilhem Saurel committed
        print(offices)
    if args.labels:
Guilhem Saurel's avatar
Guilhem Saurel committed
        logging.info(" generating door labels")
Guilhem Saurel's avatar
Guilhem Saurel committed
        labels(offices)
    if args.map:
Guilhem Saurel's avatar
Guilhem Saurel committed
        logging.info(" generating map")
Guilhem Saurel's avatar
Guilhem Saurel committed
        maps(offices, args.fixed)