From b8c9bb0ad1b75591a838f717310627d1d6b2fd03 Mon Sep 17 00:00:00 2001 From: Norm Rasmussen Date: Wed, 11 Jun 2025 16:46:35 -0400 Subject: [PATCH] First commit for this small Flask app. Logic added for following states: my service type future with attachments, my service type future no attachments, my service type past, not my service. Need to test with real attachments and add functions for adding the Id3 tags. Will also need to bundle this in a docker container for ease of deployment. --- .env.example | 14 +++ .gitignore | 4 + __pycache__/utilfuncs.cpython-313.pyc | Bin 0 -> 3184 bytes __pycache__/utils.cpython-313.pyc | Bin 0 -> 1183 bytes main.py | 141 ++++++++++++++++++++++++++ requirements.txt | 18 ++++ send_webhook.py | 13 +++ utilfuncs.py | 65 ++++++++++++ 8 files changed, 255 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 __pycache__/utilfuncs.cpython-313.pyc create mode 100644 __pycache__/utils.cpython-313.pyc create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 send_webhook.py create mode 100644 utilfuncs.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dc538c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Please go to https://api.planningcenteronline.com/oauth/applications +# Create new Personal Access Token +# Paste them below +CLIENT = "" +SECRET = "" +# USER_ID can be found via API or after logging into PCO, clicking your initials in top right of screen, and clicking "My Profile". +# The URL will be: https://services.planningcenteronline.com/people/XX{user_id}/edit +# Just grab the numbers, not the two letters before it. +USER_ID = "" +# DIR is the final resting place of your music files from PCO. Should be accessible on the same device/VM as this flask app is running. +# TODO: Add ssh possibility +DIR = "" +# Options include DEBUG, INFO, ERROR +LOGLEVEL = "INFO" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eaa422b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.* +record.log +!.gitignore +!.env.example diff --git a/__pycache__/utilfuncs.cpython-313.pyc b/__pycache__/utilfuncs.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d778712d2a5ee901249ddc00b8df4c9439249463 GIT binary patch literal 3184 zcmaJ@O>7&-6`tiT#UD{(L@6aL%kfmUEV893+o}qiXp>TLj3%}eS2WWEDzI2BXQefk zyX?%aW9iZ$C<>nnqlHnlC!s%ya%zt*S`?PsLyuA{KX)g4~K@*1jdK|e0A;pBq9I6MKqKSad;7kd!$b4(pAF4Ux`b>I>uwc zugv8FnI!etIH}8Xu~3pLt5Qk%1Q&~XxE0Qwb8`3QO9w#>? z9Q z+?JLRF&GVL3Dq4LbDi~E#|k_4RAL!C8IGkc73~o4(lfI1o2RNjB;+*tGFcAZ1Gy7K zD-dEU%R`ZDJ=Rc$$!sjrNy$m@wyo5ZRVDm2B!hfYK1mFz6xZTlTKZ+jzQwpJs9^k- zsWY{yxr~}NH5}g3Tspbn*z~ftUZ!s`jZae7p{(ubYt%JcEUnp1synT=#+jg|i>I#B z)Yhj3MVv~y##co(O=0xm7lq49Rlo&ZK$`aI|4~_FmDNg_PQK>Z^!!|zUO0DQZnBhK zVy?&S4*H7YSXFw1dtkfKuge6>Z0fEtYIq>Q%Jhn62}T{*;%}RRr8iPj^K`0Nrc-Zi zjJ?9S!ztWIpjCK_DqXx$O87~n@z#D4Ol}91Y}&4$LQ=R|tL-bnZu$+)@>l@m$J=l_ zw-om?y$%S$RA=R;KNRf;ZuN(Bk8@_bYPgh`z(7L>QvX{s4PX{CHard2M9UL`*)x|N z-D`n_n7PiKn@k6yt)FdM+PY;5cgBN+Fg#lqmG-(nYzj5>Lh#Q1SdcE2{#}eoVZ-ze zd7Mk63J3X<`}yK-zPO)1znee*yYhpyq7t9Fq;43aU2dl z7ZA6s@^&>2wBDPL1Hc$0a~X(xy{Q}(4%m;zBzaS6K?FeD6n3n09O!S6y zz}yMt8xUW?8dAUj_-D~8K^pXd(IV=AM5rB6W_9ffrF74A41*R8%hB92bs+j2TeCnC z^|nmgY9l9J>PgeI0^exhj_hGnk!Bdg3+15UN$?!`%ceMhwd`g}?8@ zX;_S*tYb_E1IqdcID#yrjlrnP4KhsX%B&n|qJ)tiySgnQk_eguh2|jlpu_`|fv4@F z(Zz$Nq4Z+cW42tA4QY8eLIkT$9KlQc52T*2%<0f9Jjr2FHxY3lAw7`)e^qC?ns6#=IP z#lO=(u7K>K<6dz*yBQSjDt*-mphcVMreT68;?t(Xggxz2b{kS6$mpBGu`39Bndwe5 zEc;#QKnAkT)l3`v&@Ig}LnG3@#1}V)I?7N#o!%Inrncik-hvq$rz1mpq@uQr4LTH& zr>k^hs>52dsb{m>V6qDav!InZdeBmuRSMrniqmGcub|Dog0A;RP<7wcIet+1*&|o- zW$1c->Xq9%3#v~^@l$9*@Fu>3q_5x$@*Ic@%oWJyPCAdGDiyV=SX|YB`&gLI|K;cm zBD@C^bfAe$Dt9pQ{9SW%bZ=y8YwRF@{AcSwS>GJ{=@0HIPh^ri^Y_vGovTmcKmkLr z_~IZ-vD@UJ+uU8)ENvd&y1J9QxR<%~QD)>IJG!4ewVOQ^aqNs;ynpQe;Dgz}h`*ly z^Q{MKJGs~QGS`6>a0*YkF8%S;eeDlt?iY4)^Lv?>@f?sHKEQ{TL55;g<^Y75QN$4)h0Pu_Uej;^#R2_7Xf& zQdMoO#Z=W#t7^+>df;W%D6J(YW@Ss CZz-t& literal 0 HcmV?d00001 diff --git a/__pycache__/utils.cpython-313.pyc b/__pycache__/utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8bf39fe139d12665603db07b0b2f2f2b06ccf69 GIT binary patch literal 1183 zcmZ`&&1(}u6o0duv`yNitx2_1G!yY)K)Zr^FxG0M3Vsm9GzaOSEW49*C7D@fHe%~V zJb6-4M30_CFOsu}pnpMxg1YAB$y*_UU@yMeCR#+_Chxs@U%&U>?(|ZrBp_KU$(1(- zz*l`35S}CI?LhGuDo`O8K|;lt0ZHnlV1Co?vO}V%EjEP0#D8xAt&d?ZEdBOYG5=V< zrU9HR4eG2M9oV!cMU$fN8f-S1EXlemCMl9OEcwycgF!6hPVN9!GOwSq;ru65j*Bj1RRmG&TIQA)R z)odx;kF_A2RulK6tSeGn$4-^yJBR#+z1r|pV6_6Tp=SNnFr(XB)}1cv7r3lJBz}_7 zg;DoWxeXiHvG`OsHTj_Mw6dPcKU?f(c0D=%@c7fkN2l&Ce9G+l5r_1?kEuNVHop(S z*q$C3@oaY_)tRu`i5Isz=@aWCbDzgE-O=%nqqz^Gxjz9%+t=C`JL%KwBc&)Ht6fvw zM7lF+waE+B89%a~INF`aZf?ZU@lzGCr#oXM0{5m5o6i%dw3GA0uT50mYW9vypUIi; zcIP#pPauCkoIW#SzMqMsUNEG-e9?$6p@%BS31s2OTvNEM2Ah{zJmSR#l|r^*5< 1: + for plan in plan_ids: + if plan == incoming_ids: + return incoming_ids[1], incoming_ids[0] + else: + return None + else: + if plan_ids[0] == incoming_ids: + return incoming_ids[1], incoming_ids[0] + else: + return None + + +def get_plans(service_type_id, plan_id, service_date): + filenames = [] + planurl = ( + f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/items" + ) + reqdata = requests.get(planurl, auth=(CLIENTSEC, SECRET)).json() + for items in reqdata["data"]: + if items["attributes"]["item_type"] == "song": + item_id = items["id"] + getkey = requests.get( + f"{planurl}/{item_id}/key", auth=(CLIENTSEC, SECRET) + ).json() + key = getkey["data"]["attributes"]["starting_key"] + filename = f"{items['attributes']['title']} - {key}" + filenames.append(filename) + planattachments = f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/items/{item_id}/media" + + planattachments = f"{BASE}/services/v2/service_types/{service_type_id}/plans/{plan_id}/all_attachments" + plana = requests.get(planattachments, auth=(CLIENTSEC, SECRET)).json() + logging.info("Looking for attachments") + if plana["data"] == []: + logging.info(f"No attachments available for {service_date}") + else: + logging.info(f"Attachments available for {service_date}") + for attachments in plana["data"]: + if attachments["attributes"]["content_type"] == "audio/mpeg": + # attachment_id = attachments["id"] + file = attachments["attributes"]["filename"] + # close_match = Utils.find_close_matches(file, filenames) + # I know this works outside of a flask app with a specific payload, but I haven't gotten this far to test functions across all possible webhook payloads + # return get_download_url(attachment_id, close_match) + return "Next function is the get_download_url" + + +def get_download_url(uuid, file): + """ + Has not been fully tested yet. + To get the s3 bucket download url, this is a sample curl url: + curl -u client_sec:secret_secret -X POST https://api.planningcenteronline.com/services/v2/media/media_id/attachments/attachment_id/open + """ + downurl = f"{BASE}/services/v2/attachments/{uuid}/open" + downreq = requests.post(downurl, auth=(CLIENTSEC, SECRET)) + downdata = downreq.json() + s3url = downdata["data"]["attributes"]["attachment_url"] + + s3response = requests.get(s3url) + with open(f"{str(file)[2:-2]}.mp3", "wb") as f: + f.write(s3response.content) + +if __name__ == "__main__": + import logging + numeric_level = getattr(logging, LOGLEVEL.upper(), None) + logging.basicConfig(filename='record.log', level=numeric_level) + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2c682d1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +black==25.1.0 +blinker==1.9.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.2.1 +flask==3.1.1 +idna==3.10 +itsdangerous==2.2.0 +jinja2==3.1.6 +markupsafe==3.0.2 +mypy-extensions==1.1.0 +packaging==25.0 +pathspec==0.12.1 +platformdirs==4.3.8 +python-dotenv==1.1.0 +requests==2.32.3 +urllib3==2.4.0 +werkzeug==3.1.3 diff --git a/send_webhook.py b/send_webhook.py new file mode 100644 index 0000000..0d32964 --- /dev/null +++ b/send_webhook.py @@ -0,0 +1,13 @@ +import json +import requests +from utilfuncs import Utils +import sys + +URL = "http://127.0.0.1:8000/webhook" +HEADERS = {"content-type": "application/json"} +file = sys.argv[1] +my_campus_payload = Utils.load_json_from_file(file) + +response = requests.post(URL, headers=HEADERS, json=json.dumps(my_campus_payload)) +print(response.status_code) +print(response.text) diff --git a/utilfuncs.py b/utilfuncs.py new file mode 100644 index 0000000..e74f5f6 --- /dev/null +++ b/utilfuncs.py @@ -0,0 +1,65 @@ +import time +import json +import datetime +import difflib + + +class Utils: + def is_future_date(service_date): + """ + Converts service_date in format "Mon Day, Year" to epoch time + and compares it to today's date. + Args: + service_date (str): Date string in format "Mon Day, Year" (e.g., "Jun 15, 2025") + Returns: + bool: True if service_date is in the future, False otherwise + """ + try: + date_obj = datetime.datetime.strptime(service_date, "%B %d, %Y") + service_epoch = int(date_obj.timestamp()) + current_epoch = int(time.time()) + return service_epoch > current_epoch + except ValueError as e: + print(f"Error parsing date: {e}") + return True + + def find_close_matches(word, possibilities, cutoff=0.4): + """ + Finds close matches for a word from a list of possibilities. + Args: + word (str): The word to find close matches for. + possibilities (list): A list of possible matches. + cutoff (float, optional): The minimum similarity ratio for a word to be considered a close match. Defaults to 0.6. + Returns: + list: A list of close matches. + """ + return difflib.get_close_matches(word, possibilities, cutoff=cutoff) + + def load_json_from_file(file_path): + """ + Reads and loads JSON data from a file. + + Args: + file_path (str): Path to the JSON file + + Returns: + dict/list: Parsed JSON data + + Raises: + FileNotFoundError: If the specified file doesn't exist + json.JSONDecodeError: If the file contains invalid JSON + """ + try: + with open(file_path, 'r') as file: + data = json.load(file) + return data + except FileNotFoundError: + print(f"Error: File '{file_path}' not found") + raise + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON format in file '{file_path}': {str(e)}") + raise + except Exception as e: + print(f"Error: An unexpected error occurred: {str(e)}") + raise +