diff --git a/.gitignore b/.gitignore index ec9c3b3..165f630 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,8 @@ MANIFEST token.ini database.db +google_credentials.json +google_token.json # PyInstaller # Usually these files are written by a python script from a template diff --git a/isartbot/checks/__init__.py b/isartbot/checks/__init__.py index 10a8769..27dcfdb 100644 --- a/isartbot/checks/__init__.py +++ b/isartbot/checks/__init__.py @@ -22,12 +22,13 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from isartbot.checks.denied import denied -from isartbot.checks.admin import is_admin -from isartbot.checks.verified import is_verified -from isartbot.checks.block_dms import block_dms -from isartbot.checks.moderator import is_moderator -from isartbot.checks.developper import is_developper, developper -from isartbot.checks.super_admin import is_super_admin, super_admin -from isartbot.checks.log_command import log_command -from isartbot.checks.trigger_typing import trigger_typing \ No newline at end of file +from isartbot.checks.denied import denied +from isartbot.checks.admin import is_admin +from isartbot.checks.verified import is_verified +from isartbot.checks.block_dms import block_dms +from isartbot.checks.moderator import is_moderator +from isartbot.checks.developper import is_developper, developper +from isartbot.checks.super_admin import is_super_admin, super_admin +from isartbot.checks.log_command import log_command +from isartbot.checks.trigger_typing import trigger_typing +from isartbot.checks.is_club_manager import is_club_manager, club_manager \ No newline at end of file diff --git a/isartbot/checks/is_club_manager.py b/isartbot/checks/is_club_manager.py new file mode 100644 index 0000000..d0979da --- /dev/null +++ b/isartbot/checks/is_club_manager.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- + +# MIT License + +# Copyright (c) 2018-2021 Renondedju + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from isartbot.checks import developper +from isartbot.exceptions import UnauthorizedCommand + +async def is_club_manager(ctx): + value = club_manager(ctx, ctx.author) or\ + ctx.author.permissions_in(ctx.channel).administrator or \ + (ctx.bot.dev_mode and developper(ctx, ctx.author)) + + if (not value): + raise UnauthorizedCommand(missing_status = await ctx.bot.get_translation(ctx, "club_manager_status", force_fetch = True)) + + return True + +def club_manager(ctx, user): + target_role = ctx.guild.get_role(ctx.bot.settings.getint('reservation', 'club_manager_role_id')) + + return target_role in user.roles diff --git a/isartbot/ext/__init__.py b/isartbot/ext/__init__.py index e462971..f656121 100644 --- a/isartbot/ext/__init__.py +++ b/isartbot/ext/__init__.py @@ -22,10 +22,11 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from .iam import * -from .ext import * -from .test import * -from .lang import * -from .game import * -from .starboard import * -from .foodtruck import * \ No newline at end of file +from .iam import * +from .ext import * +from .test import * +from .lang import * +from .game import * +from .starboard import * +from .foodtruck import * +from .reservation import * \ No newline at end of file diff --git a/isartbot/ext/reservation.py b/isartbot/ext/reservation.py new file mode 100644 index 0000000..918b70b --- /dev/null +++ b/isartbot/ext/reservation.py @@ -0,0 +1,239 @@ +# -*- coding: utf-8 -*- + +# MIT License + +# Copyright (c) 2018 - 2021 Renondedju + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import re +import os +import json +import base64 +import discord + +from datetime import datetime, timedelta +from dataclasses import dataclass +from discord.ext import tasks, commands +from email.mime.text import MIMEText +from isartbot.checks import is_club_manager +from isartbot.helper import Helper +from email.mime.multipart import MIMEMultipart +from googleapiclient.discovery import build +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request + +class ReservationExt(commands.Cog): + """ Assists club managers for room reservation """ + + __slots__ = ("bot", "creds", "calendar_service", "gmail_service", "event_statuses") + + @dataclass + class Reservation: + date: datetime + name: str + status: str + location: str + + def __str__(self) -> str: + date = self.date.strftime("%d/%m %H:%M") + + return f"{date} - {self.name} ({self.location}) - {self.status}" + + def get_mail_format(self): + date = self.date.strftime("le %d/%m à %Hh%M") + + return f"- {self.name}{date} ({self.location})" + + def __init__(self, bot): + self.bot = bot + + self.creds = self.load_credentials() + + self.calendar_service = build('calendar', 'v3', credentials=self.creds) + self.gmail_service = build('gmail', 'v1', credentials=self.creds) + + self.event_statuses = self.build_event_statuses() + + self.reservation_scan.change_interval(hours=self.bot.settings.getint('reservation', 'mailing_delay') * 24 ) + self.reservation_scan.start() + + def cog_unload(self): + self.reservation_scan.cancel() + + def load_credentials(self): + """" Loads Google credentials and writes them in a file for future loadings """ + + creds = None + google_token_file_name = self.bot.settings.get('reservation', 'google_token') + + if os.path.exists(google_token_file_name): + creds = Credentials.from_authorized_user_file(google_token_file_name) + + if (not creds or not creds.valid): + if (creds and creds.expired and creds.refresh_token): + creds.refresh(Request()) + else: + scopes = json.loads(self.bot.settings.get('reservation', 'google_api_scopes')) + google_credentials_file_name = self.bot.settings.get('reservation', 'google_credentials') + + flow = InstalledAppFlow.from_client_secrets_file(google_credentials_file_name, scopes) + + creds = flow.run_local_server(port=0) + with open(google_token_file_name, 'w') as token: + token.write(creds.to_json()) + + return creds + + def build_event_statuses(self): + """" Associates a reservation status with an icon """ + + event_statuses = {} + + for (key, icon) in self.bot.settings.items('reservation_icons'): + event_statuses[self.bot.settings.get('reservation', key)] = icon + + return event_statuses + + @tasks.loop(hours=4 * 24.0) + async def reservation_scan(self): + """ Sends a mail containing all pending reservations """ + + if self.gmail_service == None: + return + + event_list = await self.get_events_from_calendar() + pending_status = self.bot.settings.get('reservation_icons', 'pending') + reservation_lines = '\n'.join([event.get_mail_format() for event in event_list if event.status == pending_status]) + + mail_template_path = self.bot.settings.get('reservation', 'mail_template') + + if os.path.exists(mail_template_path): + with open(mail_template_path, encoding='utf-8') as mail_template_file: + mail_content = mail_template_file.read().format(reservation_lines) + + mime_message = MIMEMultipart() + mime_message['subject'] = self.bot.settings.get('reservation', 'mail_title') + mime_message['to'] = self.bot.settings.get('reservation', 'destination_mail') + mime_message.attach(MIMEText(mail_content, 'plain')) + + message = {'raw': base64.urlsafe_b64encode(mime_message.as_bytes()).decode(encoding='utf-8')} + + try: + self.gmail_service.users().messages().send(userId=self.bot.settings.get('reservation', 'sender_mail'), + body=message).execute() + except: + self.bot.logger.error("Failed to send the mail for validation") + else: + self.bot.logger.error("Failed to send the mail for validation : template is not found") + + @reservation_scan.before_loop + async def pre_reservation_scan(self): + await self.bot.wait_until_ready() + + @commands.group(invoke_without_command=True, pass_context=True, + help="reservation_help", description="reservation_description") + @commands.check(is_club_manager) + async def reservation(self, ctx): + await ctx.send_help(ctx.command) + + @reservation.command(help="reservation_list_help", description="reservation_list_description") + @commands.check(is_club_manager) + async def list(self, ctx): + """" Lists all current reservations on the Google Calendar with their status """ + + if (self.calendar_service == None): + await Helper.send_error(ctx, ctx.channel, 'reservation_list_error') + + event_list = await self.get_events_from_calendar() + + await ctx.send(embed=discord.Embed( + description = '\n'.join([str(event) for event in event_list]), + title = await ctx.bot.get_translation(ctx, 'reservation_list_title'), + color = discord.Color.green() + )) + + @reservation.command(help="reservation_notify_help", description="reservation_notify_description") + @commands.check(is_club_manager) + async def notify(self, ctx): + """" Sends a mail containing all pending reservations. Remember that a mail is already sent regularly. """ + + if self.gmail_service == None: + await Helper.send_error(ctx, ctx.channel, 'reservation_notify_error') + + await Helper.ask_confirmation(ctx, ctx.channel, 'reservation_notify_confirmation_title', + initial_content='reservation_notify_confirmation_description', success_content='reservation_notify_success', + failure_content='reservation_notify_aborted') + + event_list = await self.get_events_from_calendar() + pending_status = self.bot.settings.get('reservation_icons', 'pending') + reservation_lines = '\n'.join([event.get_mail_format() for event in event_list if event.status == pending_status]) + + mail_template_path = self.bot.settings.get('reservation', 'mail_template') + + if os.path.exists(mail_template_path): + with open(mail_template_path, encoding='utf-8') as mail_template_file: + mail_content = mail_template_file.read().format(reservation_lines) + + mime_message = MIMEMultipart() + mime_message['subject'] = self.bot.settings.get('reservation', 'mail_title') + mime_message['to'] = self.bot.settings.get('reservation', 'destination_mail') + mime_message.attach(MIMEText(mail_content, 'plain')) + + message = {'raw': base64.urlsafe_b64encode(mime_message.as_bytes()).decode(encoding='utf-8')} + + try: + self.gmail_service.users().messages().send(userId=self.bot.settings.get('reservation', 'sender_mail'), + body=message).execute() + except: + await Helper.send_error(ctx, ctx.channel, 'reservation_notify_send_error') + else: + await Helper.send_error(ctx, ctx.channel, 'reservation_notify_template_error') + + async def get_events_from_calendar(self): + """ Returns all current reservations """ + + now = datetime.utcnow() + + events = self.calendar_service.events().list(calendarId=self.bot.settings.get('reservation', 'calendar_id'), + timeMin=now.isoformat() + 'Z', singleEvents=True, orderBy='startTime').execute() + + event_list = [] + + for event in events['items']: + splitted_summary = re.split("([(].*[)])", event['summary']) + + name = splitted_summary[0] + date = datetime.strptime(event['start']['dateTime'], '%Y-%m-%dT%H:%M:%S+01:00') + status = splitted_summary[1] if len(splitted_summary) > 1 else "?" + location = event['location'] if 'location' in event else "?" + + event_list.append(self.Reservation( + name = name, + date = date, + status = self.event_statuses[status] if status in self.event_statuses else "?", + location = location + )) + + return event_list + +def setup(bot): + bot.add_cog(ReservationExt(bot)) + \ No newline at end of file diff --git a/isartbot/languages/english.lang b/isartbot/languages/english.lang index 55e8a22..299fee4 100644 --- a/isartbot/languages/english.lang +++ b/isartbot/languages/english.lang @@ -22,6 +22,7 @@ super_admin_status=super administrator developper_status=developer admin_status=administrator denied_status=god +club_manager_status=club manager command_not_found=This command does not exist. no_subcommand=This command has no subcommand. @@ -286,9 +287,29 @@ test_delay_description=Waits for 2 seconds. test_denied_help=Should never show test_denied_description=Should never execute. + ## Foodtruck foodtruck_help=Prints a list of upcoming foodtrucks foodtruck_description=Prints the list of every upcoming foodtrucks foodtruck_list_title=Upcoming foodtrucks: -foodtruck_list_empty=Such empty ¯\_(ツ)_/¯ \ No newline at end of file +foodtruck_list_empty=Such empty ¯\_(ツ)_/¯ + + +## Reservation + +reservation_help=Assists club managers for room reservation. +reservation_description=Assists club managers for room reservation. +reservation_list_help=Prints the list of all current reservations and their status. +reservation_list_description=Prints the list of all current reservations and their status. +reservation_list_title=Reservation list +reservation_list_error=Couldn't retrieve the list from the Calendar API. Please reload the extension to try fixing the issue. +reservation_notify_help=Sends a mail containing all pending reservation. +reservation_notify_description=Sends a mail containing all pending reservation. Please use it in absolute necessity since a mail is already sent regularly. +reservation_notify_confirmation_title=Send a mail for validation +reservation_notify_confirmation_description=Are you sure you want to send a mail to Emilie? +reservation_notify_success=Mail sent to Emilie! +reservation_notify_aborted=Mail not sent to Emilie. +reservation_notify_error=Unable to access the Gmail API. Please reload the extension to try fixing the issue. +reservation_notify_send_error=Unable to send the mail. Please reload the extension to try fixing the issue. +reservation_notify_template_error=Unable to send the mail: the template is not found. \ No newline at end of file diff --git a/isartbot/languages/french.lang b/isartbot/languages/french.lang index 0c59a18..5e23f9a 100644 --- a/isartbot/languages/french.lang +++ b/isartbot/languages/french.lang @@ -22,6 +22,7 @@ super_admin_status=super administrateur developper_status=developpeur admin_status=administrateur denied_status=dieu +club_manager_status=responsable club command_not_found=Cette commande n'existe pas. no_subcommand=Cette commande n'a pas de sous-commande. @@ -286,9 +287,28 @@ test_delay_description=Attendez 2 secondes. test_denied_help=Ne devrait jamais montrer test_denied_description=Ne devrait jamais s'exécuter. + ## Foodtruck foodtruck_help=Imprime une liste des foodtrucks à venir foodtruck_description=Imprime la liste de tous les foodtrucks à venir foodtruck_list_title=Foodtrucks à venir : -foodtruck_list_empty=Tellement vide ¯\_(ツ)_/¯ \ No newline at end of file +foodtruck_list_empty=Tellement vide ¯\_(ツ)_/¯ + +## Reservation + +reservation_help=Assiste les responsables de club dans les réservations de salles. +reservation_description=Assiste les responsables de club dans les réservations de salles. +reservation_list_help=Affiche la liste de toutes les réservations ainsi que leur status. +reservation_list_description=Affiche la liste de toutes les réservations ainsi que leur status. +reservation_list_title=Liste des réservations +reservation_list_error=Impossible de récupérer la liste depuis l'API Calendar. Merci de relancer l'extension pour essayer de corriger le problème. +reservation_notify_help=Envoie un mail contant les réservations à confirmer. +reservation_notify_description=Envoie un mail contant les réservations à confirmer. Merci de n'utiliser cette commande qu'en cas de nécéssité puisqu'un mail est déjà envoyé régulièrement. +reservation_notify_confirmation_title=Envoyer un mail pour validation +reservation_notify_confirmation_description=Êtes-vous sûr(e) de vouloir envoyer un mail à Émilie ? +reservation_notify_success=Mail envoyé à Émilie ! +reservation_notify_aborted=Le mail n'a pas été envoyé à Émilie. +reservation_notify_error=Impossible d'accéder à l'API Gmail. Merci de relancer l'extension pour essayer de corriger le problème. +reservation_notify_send_error=Impossible d'envoyer le mail. Merci de relancer l'extension pour essayer de corriger le problème. +reservation_notify_template_error=Impossible d'envoyer le mail : le template est introuvable. \ No newline at end of file diff --git a/mail_template.txt b/mail_template.txt new file mode 100644 index 0000000..0516050 --- /dev/null +++ b/mail_template.txt @@ -0,0 +1,6 @@ +Hello Émilie 👋 + +Il y a des réservations non-confirmées sur le calendier: +{} + +Bonne journée ! \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 077eec2..e1eadbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ discord.py emoji sqlalchemy +google-api-python-client +google-auth-httplib2 +google-auth-oauthlib \ No newline at end of file diff --git a/settings.ini b/settings.ini index 7e2504f..9871062 100644 --- a/settings.ini +++ b/settings.ini @@ -26,6 +26,7 @@ liverole=yes starboard=yes foodtruck=yes moderation=yes +reservation=yes verification=yes # Path to all the language files of the bot @@ -56,6 +57,28 @@ role_color=0x1f8b4c [iam] list_max_lines=10 +[reservation] +mailing_delay=4 +club_manager_role_id=916728222362787890 +mail_title=[Clubs] Réservation de salles/meet-up +mail_template=mail_template.txt +sender_mail=mouillade.quentin@gmail.com +destination_mail=mouillade.quentin@gmail.com +unavailable=(indispo) +pending=(à confirmer) +validated=(confirmé) +calendar_id=od3nrhdihlkojgf52fmhi2mi8o@group.calendar.google.com +google_token=google_token.json +google_credentials=google_credentials.json +google_api_scopes=["https://www.googleapis.com/auth/calendar.calendarlist.readonly", + "https://www.googleapis.com/auth/calendar.events", + "https://www.googleapis.com/auth/gmail.send"] + +[reservation_icons] +unavailable=:x: +pending=:hourglass: +validated=:white_check_mark: + # Logging # see https://docs.python.org/3/library/logging.config.html for more information # about the following section