diff --git a/bot/callbacks/categories.py b/bot/callbacks/categories.py index 6bc9d54..cd1460e 100644 --- a/bot/callbacks/categories.py +++ b/bot/callbacks/categories.py @@ -4,7 +4,6 @@ from bot.handlers.start import startMarkup from bot.utils.crud_helpers import create_entity_markup # TODO: Add Option for showing parent categories when creating a new category. -# TODO: Show Some of the courses of a Category. def register(bot: TeleBot): @@ -14,8 +13,8 @@ def register(bot: TeleBot): CATEGORY_FIELDS = [ ('name', "the category's name"), - ('description', "The description for category"), - ('parent_id', "The parent category id") + ('description', "The description for category () ('s' to skip)"), + ('parent_id', "The parent category id () ('s' to skip))") ] EDITABLE_FIELDS = { 'name': 1, @@ -27,7 +26,7 @@ def register(bot: TeleBot): @bot.callback_query_handler(func=lambda call: call.data.startswith('category_')) def show_category_details(call): category_id = call.data.split('_')[1] - category = Categories.getCategorieById(category_id) + category = Categories.getCategoryById(category_id) if category: details = f""" @@ -39,6 +38,8 @@ def register(bot: TeleBot): """ details.strip() markup = create_entity_markup("category", category_id, True) + markup.add(types.InlineKeyboardButton("📁 Show Top Courses", + callback_data=f"categoryCourses_{category_id}")) bot.send_message(call.message.chat.id, details, reply_markup=markup, parse_mode="HTML") @@ -60,7 +61,8 @@ def register(bot: TeleBot): # Save previous field if step > 0: field_name = CATEGORY_FIELDS[step - 1][0] - data[field_name] = message.text + data[field_name] = None if ( + (field_name in ['description', 'parent_id']) and message.text == 's') else message.text # Done collecting? if step >= len(CATEGORY_FIELDS): @@ -99,7 +101,7 @@ def register(bot: TeleBot): return category_id = call.data.split('_')[2] - category = Categories.getCategorieById(category_id) + category = Categories.getCategoryById(category_id) if not category: bot.send_message(call.message.chat.id, "Category not found.") @@ -167,3 +169,22 @@ def register(bot: TeleBot): bot.send_message(call.message.chat.id, "❌ Failed to delete Category.", reply_markup=startMarkup()) bot.answer_callback_query(call.id) + + # Show Top Courses in a Category + @bot.callback_query_handler(func=lambda call: call.data.startswith('categoryCourses_')) + def category_courses(call): + category_id = call.data.split('_')[1] + courses = Categories.getTopCoursesByCategory(category_id) + + markup = types.InlineKeyboardMarkup() + if courses: + for course in courses: + markup.add(types.InlineKeyboardButton( + course[1], callback_data=f"course_{course[0]}")) + bot.send_message( + call.message.chat.id, "📚 Top Courses in this Category:", reply_markup=markup) + else: + bot.send_message( + call.message.chat.id, "There are no courses in this category.", reply_markup=markup) + + bot.answer_callback_query(call.id) diff --git a/bot/callbacks/courses.py b/bot/callbacks/courses.py index fd802c4..63a8de3 100644 --- a/bot/callbacks/courses.py +++ b/bot/callbacks/courses.py @@ -1,7 +1,7 @@ from telebot import TeleBot, types from database.models import Courses, Admins from bot.utils.crud_helpers import create_entity_markup -from bot.utils.formatters import format_course_info +from bot.utils.formatters import format_course_info, format_course_review from bot.handlers.start import startMarkup # TODO: Add Option for selecting Teacher in a more user-friendly way (name instead of ID) + pagination for it. @@ -16,9 +16,9 @@ def register(bot: TeleBot): COURSE_FIELDS = [ ('name', "the course's name"), ('teacher_id', "The course's Teacher ID"), - ('description', "description ()"), + ('description', "description () ('s' to skip)"), ('language', "language ('english', 'spanish', 'german', 'french', 'persian')"), - ('difficulty', "difficulty () ('beginner', 'intermediate', 'expert')"), + ('difficulty', "difficulty () ('s' to skip) ('beginner', 'intermediate', 'expert')"), ] EDITABLE_FIELDS = { 'name': 1, @@ -35,12 +35,15 @@ def register(bot: TeleBot): if not course: bot.send_message(call.message.chat.id, "Course not found.") + bot.answer_callback_query(call.id) return details = format_course_info(course) markup = create_entity_markup("course", course_id, True) markup.add(types.InlineKeyboardButton( f"👨‍🏫 Teacher's Info", callback_data=f"teacher_{course[3]}")) + markup.add(types.InlineKeyboardButton( + f" 📰 Course Reviews", callback_data=f"courseReviews_{course_id}")) bot.send_message(call.message.chat.id, details, reply_markup=markup, parse_mode="HTML") @@ -60,7 +63,8 @@ def register(bot: TeleBot): # Save previous field if step > 0: field_name = COURSE_FIELDS[step - 1][0] - data[field_name] = message.text + data[field_name] = None if ( + (field_name in ['description', 'difficulty']) and message.text == 's') else message.text # Done collecting? if step >= len(COURSE_FIELDS): @@ -167,3 +171,19 @@ def register(bot: TeleBot): bot.send_message(call.message.chat.id, "❌ Failed to delete Course.", reply_markup=startMarkup()) bot.answer_callback_query(call.id) + + # Show Some of the course reviews + @bot.callback_query_handler(func=lambda call: call.data.startswith('courseReviews_')) + def show_course_reviews(call): + course_id = call.data.split('_')[1] + reviews = Courses.getCourseReviews(course_id) + + if reviews: + for rev in reviews: + details = format_course_review(rev) + bot.send_message(call.message.chat.id, + details, parse_mode="HTML") + else: + bot.send_message(call.message.chat.id, "No reviews yet") + + bot.answer_callback_query(call.id) diff --git a/bot/callbacks/init.py b/bot/callbacks/init.py index 18d11ae..31d25f6 100644 --- a/bot/callbacks/init.py +++ b/bot/callbacks/init.py @@ -1,6 +1,9 @@ from . import students, teachers, courses, tags, categories from bot.handlers.start import startMarkup +# TODO: Create a new entity for Reviews (Change The Corresponding codes from courses and students to use that) +# TODO: Add Option for handling all updates at once. + def register_all_callbacks(bot): """Register all callback handlers""" diff --git a/bot/callbacks/students.py b/bot/callbacks/students.py index 1baa0ed..c65e745 100644 --- a/bot/callbacks/students.py +++ b/bot/callbacks/students.py @@ -1,6 +1,6 @@ from telebot import types, TeleBot from database.models import Students, Admins -from bot.utils.formatters import format_student_info +from bot.utils.formatters import format_course_review, format_student_info from bot.utils.crud_helpers import create_entity_markup from bot.handlers.start import startMarkup @@ -13,7 +13,7 @@ def register(bot: TeleBot): STUDENT_FIELDS = [ ('name', "the student's name"), ('email', "email"), - ('phone_number', "phone ()"), + ('phone_number', "phone () ('s' to skip)"), ('password', "password"), ('username', "username"), ('birthday', "birthday (YY/MM/DD)"), @@ -41,6 +41,10 @@ def register(bot: TeleBot): if student: details = format_student_info(student) markup = create_entity_markup("student", student_id, True) + markup.add(types.InlineKeyboardButton("📚 Show Courses 🔒", + callback_data=f"studentCourses_{student_id}")) + markup.add(types.InlineKeyboardButton("📰 Reviews By Student 🔒", + callback_data=f"studentReviews_{student_id}")) bot.send_message(call.message.chat.id, details, reply_markup=markup, parse_mode="HTML") @@ -67,7 +71,8 @@ def register(bot: TeleBot): # Save previous field if step > 0: field_name = STUDENT_FIELDS[step - 1][0] - data[field_name] = message.text + data[field_name] = None if ( + (field_name == 'phone_number') and message.text == 's') else message.text # Done collecting? if step >= len(STUDENT_FIELDS): @@ -174,3 +179,50 @@ def register(bot: TeleBot): bot.send_message(call.message.chat.id, "❌ Failed to delete Student.", reply_markup=startMarkup()) bot.answer_callback_query(call.id) + + # Showing Courses a Students has enrolled in + @bot.callback_query_handler(func=lambda call: call.data.startswith("studentCourses_")) + def show_student_course(call): + if not Admins.is_authenticated(call.from_user.id): + bot.answer_callback_query(call.id, "⛔ Unauthorized access!") + bot.send_message(call.message.chat.id, "Please /login first.") + return + + student_id = call.data.split('_')[1] + courses = Students.getStudentCourses(student_id) + + if courses: + markup = types.InlineKeyboardMarkup(row_width=1) + for c in courses: + markup.add(types.InlineKeyboardButton( + f"{c[1][:26] + "..."} | id#{c[0]}", callback_data=f"course_{c[0]}") + ) + + bot.send_message(call.message.chat.id, + "Courses: ", reply_markup=markup) + else: + bot.send_message(call.message.chat.id, "No courses.") + + bot.answer_callback_query(call.id) + + # Showing Reviews a Students has written + @bot.callback_query_handler(func=lambda call: call.data.startswith("studentReviews_")) + def show_student_reviews(call): + if not Admins.is_authenticated(call.from_user.id): + bot.answer_callback_query(call.id, "⛔ Unauthorized access!") + bot.send_message(call.message.chat.id, "Please /login first.") + return + + student_id = call.data.split('_')[1] + reviews = Students.getStudentReviews(student_id) + + if reviews: + for rev in reviews: + details = format_course_review(rev) + bot.send_message(call.message.chat.id, + details, parse_mode="HTML") + + else: + bot.send_message(call.message.chat.id, "No reviews.") + + bot.answer_callback_query(call.id) diff --git a/bot/callbacks/tags.py b/bot/callbacks/tags.py index 5c9575e..23e710b 100644 --- a/bot/callbacks/tags.py +++ b/bot/callbacks/tags.py @@ -3,8 +3,6 @@ from database.models import Tags, Admins from bot.handlers.start import startMarkup from bot.utils.crud_helpers import create_entity_markup -# TODO: Add Option for showing courses with specific tags. - def register(bot: TeleBot): cancelMarkup = types.InlineKeyboardMarkup() @@ -35,6 +33,8 @@ def register(bot: TeleBot): """ details.strip() markup = create_entity_markup("tag", tag_id, True) + markup.add(types.InlineKeyboardButton("📁 Show Top Courses", + callback_data=f"tagCourses_{tag_id}")) bot.send_message(call.message.chat.id, details, reply_markup=markup, parse_mode="HTML") @@ -163,3 +163,22 @@ def register(bot: TeleBot): bot.send_message(call.message.chat.id, "❌ Failed to delete Tag.", reply_markup=startMarkup()) bot.answer_callback_query(call.id) + + # Show Top Courses with a Tag + @bot.callback_query_handler(func=lambda call: call.data.startswith('tagCourses_')) + def delete_tag(call): + tag_id = call.data.split('_')[1] + courses = Tags.getTopCoursesByTag(tag_id) + + markup = types.InlineKeyboardMarkup() + if courses: + for course in courses: + markup.add(types.InlineKeyboardButton( + course[1], callback_data=f"course_{course[0]}")) + bot.send_message( + call.message.chat.id, "📚 Top Courses with this Tag:", reply_markup=markup) + else: + bot.send_message( + call.message.chat.id, "There are no courses with this Tag.", reply_markup=markup) + + bot.answer_callback_query(call.id) diff --git a/bot/callbacks/teachers.py b/bot/callbacks/teachers.py index 63236dd..5ca914b 100644 --- a/bot/callbacks/teachers.py +++ b/bot/callbacks/teachers.py @@ -4,11 +4,6 @@ from bot.utils.formatters import format_teacher_info from bot.handlers.start import startMarkup from bot.utils.crud_helpers import create_entity_markup -# TODO: Add Buttons for skipping Optional Fields (Add to all entities). -# TODO: Add Option for handling all updates at once.\ - -# TODO: Add Option for seeing courses taught by a teacher in Teacher Details. - def register(bot: TeleBot): cancelMarkup = types.InlineKeyboardMarkup() @@ -18,7 +13,7 @@ def register(bot: TeleBot): TEACHER_FIELDS = [ ('name', "the teacher's name"), ('email', "email"), - ('phone_number', "phone ()"), + ('phone_number', "phone () ('s' to skip)"), ('password', "password"), ('username', "username"), ('birthday', "birthday (YY/MM/DD)"), @@ -71,7 +66,8 @@ def register(bot: TeleBot): # Save previous field if step > 0: field_name = TEACHER_FIELDS[step - 1][0] - data[field_name] = message.text + data[field_name] = None if ( + (field_name == 'phone_number') and message.text == 's') else message.text # Done collecting? if step >= len(TEACHER_FIELDS): diff --git a/bot/utils/crud_helpers.py b/bot/utils/crud_helpers.py index a12e1b2..0cdf619 100644 --- a/bot/utils/crud_helpers.py +++ b/bot/utils/crud_helpers.py @@ -6,6 +6,6 @@ def create_entity_markup(entity_name, enitity_id, is_auth_needed=False): markup.add(types.InlineKeyboardButton( f"✏️ Edit {entity_name} " + ("🔒" if is_auth_needed else ""), callback_data=f"edit_{entity_name}_{enitity_id}")) markup.add(types.InlineKeyboardButton( - f"🗑️ Delete {entity_name}" + ("🔒" if is_auth_needed else ""), callback_data=f"delete_{entity_name}_{enitity_id}")) + f"🗑️ Delete {entity_name} " + ("🔒" if is_auth_needed else ""), callback_data=f"delete_{entity_name}_{enitity_id}")) return markup diff --git a/bot/utils/formatters.py b/bot/utils/formatters.py index 3f756db..5d17531 100644 --- a/bot/utils/formatters.py +++ b/bot/utils/formatters.py @@ -115,11 +115,11 @@ def format_teacher_info(teacher): def format_course_info(course): """ Format course data into a nice message - course is a tuple: (id, name, created_at, teacher_id, updated_at, description, difficulty, language) + course is a tuple: (id, name, created_at, teacher_id, updated_at, description, difficulty, language, avgerage_rate) """ # print(course) - id, name, created_at, teacher_id, updated_at, description, difficulty, language = course + id, name, created_at, teacher_id, updated_at, description, difficulty, language, avgerage_rate = course # Format dates nicely created_date = format_date(created_at) @@ -129,6 +129,7 @@ def format_course_info(course): 📚 Course Profile Name: {name} + ⭐️ Average Rating: {avgerage_rate} Description: {description} @@ -141,3 +142,30 @@ def format_course_info(course): ID: {id} """ return details.strip() + + +def format_course_review(review): + """ + Formate course review into a nice message + :param review: tuple(body: str, rate: int, student_name: str, course_name: str, created_at: date, updated_at: date) + """ + + reviewBody, rate, student_name, course_name, created_at, updated_at = review + + created_date = format_date(created_at) + updated_at = format_date(updated_at) + + details = f""" + ✉️ Review + + rate: {'⭐️' * rate} + Body:\n{reviewBody} + + Course name: {course_name} + Student name: {student_name} + + 📅 Review Info + Created At: {created_date} + Last Updated At: {updated_at} + """ + return details.strip() diff --git a/database/connection.py b/database/connection.py index 7e66c4e..a6b1244 100644 --- a/database/connection.py +++ b/database/connection.py @@ -5,28 +5,26 @@ from psycopg2 import pool logger = logging.getLogger(__name__) -# Connection pool for reusing connections (important for serverless!) +# Connection pool for reusing connections _connection_pool = None + def get_connection_pool(): """Create a connection pool""" global _connection_pool if _connection_pool is None: try: - # --- Production settings - # DATABASE_URL = os.environ.get("DATABASE_URL") - # _connection_pool = psycopg2.pool.SimpleConnectionPool(1, 5, DATABASE_URL); - - # --- Development settings - dev_url = "postgresql://postgres:123@localhost:5432/OLP" - _connection_pool = psycopg2.pool.SimpleConnectionPool(1, 5, dev_url); - + DATABASE_URL = os.environ.get("DATABASE_URL") + _connection_pool = psycopg2.pool.SimpleConnectionPool( + 1, 5, DATABASE_URL) + logger.info("Database connection pool created") except Exception as e: logger.error(f"Failed to create connection pool: {e}") return None return _connection_pool + def get_db_connection(): """Get a connection from the pool""" try: @@ -39,6 +37,7 @@ def get_db_connection(): logger.error(f"Database connection error: {e}") return None + def release_db_connection(conn): """Return connection to the pool""" try: @@ -46,4 +45,4 @@ def release_db_connection(conn): if pool and conn: pool.putconn(conn) except Exception as e: - logger.error(f"Error releasing connection: {e}") \ No newline at end of file + logger.error(f"Error releasing connection: {e}") diff --git a/database/models.py b/database/models.py index 40ae1e2..f0b5de3 100644 --- a/database/models.py +++ b/database/models.py @@ -227,6 +227,44 @@ class Students(BaseRepository): return cls._execute_mutation(mutation, "deleteStudent") + @classmethod + def getStudentCourses(cls, student_id): + def query(cursor): + cursor.execute( + f""" + SELECT + c.id, + c.name + FROM courses c + JOIN course_enrolments ce ON ce.course_id = c.id + JOIN students s ON s.id = ce.student_id + WHERE s.id = %s + """, (student_id,) + ) + result = cursor.fetchall() + return result if result else None + + return cls._execute_query(query, 'getStudentCourses') + + @classmethod + def getStudentReviews(cls, student_id): + def query(cursor): + cursor.execute( + f""" + SELECT r.body review, r.rate, s.name student_name, c.name course_name, r.created_at, r.updated_at + FROM reviews r + JOIN course_enrolments ce ON ce.id = r.enrolment_id + JOIN students s ON s.id = ce.student_id + JOIN courses c ON c.id = ce.course_id + WHERE s.id = %s + LIMIT (3) + """, (student_id,) + ) + result = cursor.fetchall() + return result if result else None + + return cls._execute_query(query, 'getStudentReviews') + class Teachers(BaseRepository): table_name = "teachers" @@ -302,7 +340,31 @@ class Courses(BaseRepository): def getCourseById(cls, course_id): def query(cursor): cursor.execute( - f"SELECT id, name, created_at, teacher_id, updated_at, description, difficulty, language FROM {cls.table_name} WHERE id = %s", (course_id, )) + f""" + SELECT + id, + name, + created_at, + teacher_id, + updated_at, + description, + difficulty, + language, + r.avg_rate + FROM + courses + LEFT JOIN ( + SELECT + ce.course_id, + ROUND(AVG(r.rate), 2) avg_rate + FROM course_enrolments ce + JOIN reviews r ON r.enrolment_id = ce.id + GROUP BY ce.course_id + ) r ON r.course_id = courses.id + WHERE + id = %s + + """, (course_id, )) result = cursor.fetchall() return result[0] if result else None @@ -339,6 +401,23 @@ class Courses(BaseRepository): return cls._execute_mutation(mutation, "deleteCourse") + @classmethod + def getCourseReviews(cls, course_id): + def query(cursor): + cursor.execute(f""" + SELECT r.body review, r.rate, s.name student_name, c.name course_name, r.created_at, r.updated_at + FROM reviews r + JOIN course_enrolments ce ON ce.id = r.enrolment_id + JOIN courses c ON c.id = ce.course_id + JOIN students s ON s.id = ce.student_id + WHERE c.id = %s + LIMIT (3) + """, (course_id,)) + result = cursor.fetchall() + return result if result else None + + return cls._execute_query(query, 'getCourseReviews') + class Tags(BaseRepository): table_name = "tags" @@ -393,6 +472,36 @@ class Tags(BaseRepository): return cls._execute_mutation(mutation, "deleteTag") + @classmethod + def getTopCoursesByTag(cls, tag_id): + def query(cursor): + cursor.execute( + """ + SELECT + cs.id, + cs.name course_name, + avg(r.rate) avg_rate + FROM + tags t + JOIN course_tags ct ON t.id = ct.tag_id + JOIN courses cs ON cs.id = ct.course_id + JOIN course_enrolments ce ON cs.id = ce.course_id + JOIN reviews r ON ce.id = r.enrolment_id + WHERE + t.id = %s + GROUP BY + cs.id, + cs.name + ORDER BY + avg_rate DESC + LIMIT + (5) + """, (tag_id, )) + result = cursor.fetchall() + return result if result else None + + return cls._execute_query(query, "getTopCoursesByTag") + class Categories(BaseRepository): table_name = "categories" @@ -408,7 +517,7 @@ class Categories(BaseRepository): return cls._execute_query(query, "getAllCategories") @classmethod - def getCategorieById(cls, category_id): + def getCategoryById(cls, category_id): def query(cursor): cursor.execute(f""" SELECT c1.id, c1.name, c1.description, @@ -452,3 +561,33 @@ class Categories(BaseRepository): f"DELETE FROM {cls.table_name} WHERE id = %s", (category_id,)) return cls._execute_mutation(mutation, "deleteCategory") + + @classmethod + def getTopCoursesByCategory(cls, category_id): + def query(cursor): + cursor.execute( + """ + SELECT + cs.id, + cs.name course_name, + avg(r.rate) avg_rate + FROM + categories c + JOIN course_categories cc ON c.id = cc.category_id + JOIN courses cs ON cc.course_id = cs.id + JOIN course_enrolments ce ON cs.id = ce.course_id + JOIN reviews r ON ce.id = r.enrolment_id + WHERE + c.id = %s + GROUP BY + cs.id, + cs.name + ORDER BY + avg_rate DESC + LIMIT + (5) + """, (category_id, )) + result = cursor.fetchall() + return result if result else None + + return cls._execute_query(query, "getTopCoursesByCategory")