from dataclasses import dataclass from enum import Enum, auto import logging import logging.config from logging import Logger from pathlib import Path from typing import Dict, List, Optional from uuid import UUID from platformdirs import PlatformDirs import requests import yaml MAPPING: Dict[str, str] = { "sport": "api/tysc/sports", "player": "api/tysc/players", "team": "api/tysc/teams", "field_position": "api/tysc/positions", "rooster": "api/tysc/roosters", "vendor": "api/tysc/vendors", "card_set": "api/tysc/cardsets", "card": "api/tysc/cards", "artist": "api/comics/artists", "publisher": "api/comics/publishers", "worktype": "api/comics/worktypes", "comic": "api/comics/comics", "volume": "api/comics/volumes", "story_arc": "api/comics/storyarcs", "issue": "api/comics/issues", "comic_work": "api/comics/comicworks", "issue_work": "api/comics/issueworks", "article": "api/bookshelf/articles", "bookshelf_publisher": "api/bookshelf/publishers", "book": "api/bookshelf/books", "author": "api/bookshelf/authors", "article_author": "api/bookshelf/articleauthors", "book_author": "api/bookshelf/bookauthors", "media_article": "api/media/articles", "media_video": "api/media/videos", "media_file": "api/media/files", "media_actor": "api/media/actors", "media_actor_file": "api/media/actorfiles", "profile": "api/user/profiles", "permission": "api/user/permissions", "assignment": "api/user/assignments", "token": "api/user/tokens", "mail_account": "api/admin/mailaccounts", } class OptionType(Enum): """ OptionType defines the type of param for REST API call. The type PARAM indicates a query parameter. The type ID indicates the option is an idntifier as part of the path. """ PARAM = auto() ID = auto() URL = auto() class Option: """ Option is an utility class to simplify options for the REST API call. The type defines how to handle the value. """ def __init__(self, option_type: OptionType, value: str) -> None: self.type: Optional[OptionType] = option_type self.value: Optional[str] = value def __str__(self) -> str: if self.type is OptionType.PARAM: return f"?{self.value}" else: return f"/{self.value}" class CredentialsValidationException(Exception): """ Raised when login failed or token is outdated. """ class EndPointNotAvailableException(Exception): """ Raised when calling an not existing endpoint. """ @dataclass class Login: """ Dataclass to store login information. """ email: str password: str @dataclass class Server: """ Dataclass to represent a Kontor-API instance. """ name: str url: str token: str token_type: str email: str password: str timeout: int def login(self, log: Logger, refresh_token: bool = False): """ get token from server by calling login endpoint. """ log.debug("call login and retrieve token") if not self.token: self.__get_token__(log=log) if refresh_token: self.__get_token__(log=log) def __get_token__(self, log: Logger): log.info("Call login first") login_url = f"{self.url}/login" login_data = {} login_data["email"] = self.email login_data["password"] = self.password response = requests.post(login_url, json=login_data, timeout=self.timeout) status = response.status_code log.info(f"Status: {status}") if status != 200: log.fatal("authentication failed") return data = response.json() log.debug(f"got data: {data}") token = data["access_token"] token_type = data["token_type"] self.token = str(token) self.token_type = str(token_type) def request(self, log: Logger, table: str, param: Optional[Option] = None): """ Requests data from Kontor-API instance by given table and optional parameters. """ if not param: url: str = f"{self.url}/{MAPPING[table]}" else: url: str = f"{self.url}/{MAPPING[table]}{str(param)}" headers: Dict[str, str] = {"Authorization": f"Bearer {self.token}"} response = requests.get(url, headers=headers, timeout=self.timeout) log.debug(f"Status: {response.status_code}") if response.status_code == 401: self.login(log, refresh_token=True) headers: Dict[str, str] = {"Authorization": f"Bearer {self.token}"} response = requests.get(url, headers=headers, timeout=self.timeout) if response.status_code == 404: raise EndPointNotAvailableException data = response.json() return data def update(self, log: Logger, table: str, item_id: UUID, file_info: dict): """ Updates data to the Kontor-API instance. """ url: str = f"{self.url}/{MAPPING[table]}/{item_id}" headers: Dict[str, str] = {"Authorization": f"Bearer {self.token}"} update = requests.put( url, headers=headers, json=file_info, timeout=self.timeout ) log.info(f"Status: {update.status_code}") if update.status_code == 404: raise EndPointNotAvailableException data = update.json() return data def create(self, log: Logger, table: str, new_item: dict): """ Create item in Kontor-API instance. """ url: str = f"{self.url}/{MAPPING[table]}" headers: Dict[str, str] = {"Authorization": f"Bearer {self.token}"} create = requests.post( url, headers=headers, json=new_item, timeout=self.timeout ) log.info(f"Status: {create.status_code}") if create.status_code == 404: raise EndPointNotAvailableException if create.status_code == 409: log.fatal("Create Exception %s", create.json()) data = create.json() return data def delete(self, log: Logger, table: str, item_id: str): """ Delete item in Kontor-API instance. """ url: str = f"{self.url}/{MAPPING[table]}/{item_id}" headers: Dict[str, str] = {"Authorization": f"Bearer {self.token}"} delete = requests.delete(url, headers=headers, timeout=self.timeout) log.info(f"Status: {delete.status_code}") @dataclass class ApiConfig: """ Dataclass to define required contents of configuration file. """ server: List[Server] def get_server(self, server_name: str) -> Optional[Server]: """ Return server instance by given name or None. """ found_server = None for server in self.server: if server.name == server_name: found_server = server return found_server def get_logger(level, config: str): """ get Logger according to given log level by verbosity. """ dirs = PlatformDirs(config) logging_config = Path(dirs.user_config_dir, "logging-config.yaml") log_config = None with open(logging_config, "rt", encoding="utf-8") as f: log_config = yaml.safe_load(f.read()) logging.config.dictConfig(log_config) logger = logging.getLogger("development") if level is not None: match level: case 0: logger.setLevel(logging.CRITICAL) logging.getLogger("requests").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING) case 1: logging.getLogger("requests").setLevel(logging.INFO) logging.getLogger("urllib3").setLevel(logging.INFO) logger.setLevel(logging.INFO) case 2: logger.setLevel(logging.DEBUG) logging.getLogger("requests").setLevel(logging.DEBUG) logging.getLogger("urllib3").setLevel(logging.DEBUG) case _: logger.setLevel(logging.INFO) logging.getLogger("requests").setLevel(logging.INFO) logging.getLogger("urllib3").setLevel(logging.INFO) return logger def get_api_config(log: Logger, config: str) -> ApiConfig: """ Load configuration from file. """ dirs = PlatformDirs(config) api_config = Path(dirs.user_config_dir, "api.yaml") with open(api_config, "rt", encoding="utf-8") as f: api_data = yaml.safe_load(f.read()) servers = [Server(**server) for server in api_data["server"]] api_config_data = ApiConfig(server=servers) log.debug(api_config_data) if not api_data: log.fatal("API configuration is missing") return api_config_data for server in api_config_data.server: server.login(log) with open(api_config, "w", encoding="utf-8") as f: yaml.dump(api_data, f) return api_config_data