diff --git a/.gitignore b/.gitignore index 8d0d0d75..dd4995aa 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ pyproject.toml000 app.log backend/app.log tests/.env -tests/cypress/screenshots/ \ No newline at end of file +tests/cypress/screenshots/ + +.cursor \ No newline at end of file diff --git a/backend/api/utils/profile_parse.py b/backend/api/utils/profile_parse.py index 94265981..6c218244 100644 --- a/backend/api/utils/profile_parse.py +++ b/backend/api/utils/profile_parse.py @@ -332,9 +332,11 @@ def edit_profile(data: dict = {}, profile: object = None): title=video_data.get("title"), url=video_data.get("url"), tag=video_data.get("tag"), - date_uploaded=video_data.get("date_uploaded")["$date"] - if "$date" in video_data.get("date_uploaded") - else video_data.get("date_uploaded"), + date_uploaded=( + video_data.get("date_uploaded")["$date"] + if "$date" in video_data.get("date_uploaded") + else video_data.get("date_uploaded") + ), ) if profile.videos: profile.videos[0] = profile.video @@ -353,9 +355,11 @@ def edit_profile(data: dict = {}, profile: object = None): title=video.get("title"), url=video.get("url"), tag=video.get("tag"), - date_uploaded=video.get("date_uploaded")["$date"] - if "$date" in video.get("date_uploaded") - else video.get("date_uploaded"), + date_uploaded=( + video.get("date_uploaded")["$date"] + if "$date" in video.get("date_uploaded") + else video.get("date_uploaded") + ), ) for video in video_data ] @@ -413,9 +417,11 @@ def edit_profile(data: dict = {}, profile: object = None): title=video_data.get("title"), url=video_data.get("url"), tag=video_data.get("tag"), - date_uploaded=video_data.get("date_uploaded")["$date"] - if "$date" in video_data.get("date_uploaded") - else video_data.get("date_uploaded"), + date_uploaded=( + video_data.get("date_uploaded")["$date"] + if "$date" in video_data.get("date_uploaded") + else video_data.get("date_uploaded") + ), ) else: profile.video = None diff --git a/backend/api/views/download.py b/backend/api/views/download.py index b34f4147..242cf63a 100644 --- a/backend/api/views/download.py +++ b/backend/api/views/download.py @@ -528,7 +528,7 @@ def generate_sheet(sheet_name, row_data, columns): worksheet = writer.sheets[sheet_name] format = workbook.add_format() format.set_bg_color("#eeeeee") - worksheet.set_column(0, len(row_data[0]), 28) + worksheet.set_column(0, len(row_data[0]) if row_data else 0, 28) writer.close() output.seek(0) @@ -544,3 +544,317 @@ def generate_sheet(sheet_name, row_data, columns): msg = "Downloads failed" logger.info(msg) return create_response(status=422, message=msg) + + +def generate_file(file_name, row_data, columns, file_format="xlsx"): + """Generate either CSV or Excel file based on format parameter.""" + df = pd.DataFrame(row_data, columns=columns) + output = BytesIO() + + if file_format == "csv": + # Generate CSV + csv_data = df.to_csv(index=False) + output.write(csv_data.encode("utf-8")) + output.seek(0) + + try: + return send_file( + output, + mimetype="text/csv", + download_name="{0}.csv".format(file_name), + as_attachment=True, + ) + except FileNotFoundError: + msg = "Downloads failed" + logger.info(msg) + return create_response(status=422, message=msg) + else: + # Generate Excel (default) + writer = pd.ExcelWriter(output, engine="xlsxwriter") + + df.to_excel( + writer, startrow=0, merge_cells=False, sheet_name=file_name, index=False + ) + workbook = writer.book + worksheet = writer.sheets[file_name] + format = workbook.add_format() + format.set_bg_color("#eeeeee") + worksheet.set_column(0, len(row_data[0]) if row_data else 0, 28) + + writer.close() + output.seek(0) + + try: + return send_file( + output, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + download_name="{0}.xlsx".format(file_name), + as_attachment=True, + ) + except FileNotFoundError: + msg = "Downloads failed" + logger.info(msg) + return create_response(status=422, message=msg) + + +@download.route("/partner//accounts", methods=["GET"]) +@admin_only +def download_partner_accounts_data(partner_id): + """Download mentors or mentees assigned to a specific partner with activity status.""" + data = request.args + account_type = int(data.get("account_type", 0)) + file_format = data.get("format", "xlsx") # 'xlsx' or 'csv' + + try: + partner = PartnerProfile.objects.get(id=partner_id) + except: + msg = "Partner not found" + logger.info(msg) + return create_response(status=422, message=msg) + + messages = DirectMessage.objects() + + if account_type == Account.MENTOR: + return download_partner_mentor_accounts(partner, messages, file_format) + elif account_type == Account.MENTEE: + return download_partner_mentee_accounts(partner, messages, file_format) + + msg = "Invalid account type" + logger.info(msg) + return create_response(status=422, message=msg) + + +def download_partner_mentor_accounts(partner, messages, file_format="xlsx"): + """Download mentors assigned to a partner with activity status.""" + if not partner.assign_mentors: + return generate_file( + "partner_mentors", [], get_mentor_columns_with_activity(), file_format + ) + + accts = [] + + for mentor_ref in partner.assign_mentors: + mentor_id = mentor_ref.get("id") + if not mentor_id: + continue + + try: + mentor = MentorProfile.objects.get(id=mentor_id) + except: + continue + + # Count messages + sent_messages = [ + msg for msg in messages if str(msg.sender_id) == str(mentor.id) + ] + received_messages = [ + msg for msg in messages if str(msg.recipient_id) == str(mentor.id) + ] + + # Determine activity status + total_sent = len(sent_messages) + total_received = len(received_messages) + has_messages = total_sent > 0 or total_received > 0 + + if has_messages: + is_active = "Yes" + activity_reason = f"Sent: {total_sent}, Received: {total_received} messages" + else: + is_active = "No" + activity_reason = "No messages sent or received" + + # Format educations + educations = [] + for edu in mentor.education: + educations.append( + "{0} from {1} ({2})".format( + edu.education_level if edu.education_level else "", + edu.school if edu.school else "", + edu.graduation_year if edu.graduation_year else "", + ) + ) + + accts.append( + [ + mentor.name, + mentor.email, + mentor.professional_title, + mentor.linkedin, + mentor.website, + "Yes" if mentor.image and mentor.image.url else "No", + mentor.image.url if mentor.image else "", + ( + mentor.videos[0].url + if mentor.videos and len(mentor.videos) > 0 + else "" + ), + "Yes" if mentor.videos and len(mentor.videos) > 0 else "No", + "|".join(educations), + ",".join(mentor.languages) if mentor.languages else "", + ",".join(mentor.specializations) if mentor.specializations else "", + mentor.biography, + "Yes" if mentor.taking_appointments else "No", + "Yes" if mentor.offers_in_person else "No", + "Yes" if mentor.offers_group_appointments else "No", + "Yes" if mentor.text_notifications else "No", + "Yes" if mentor.email_notifications else "No", + total_received, + total_sent, + partner.organization if partner.organization else "", + is_active, + activity_reason, + ] + ) + + return generate_file( + "partner_mentors", accts, get_mentor_columns_with_activity(), file_format + ) + + +def download_partner_mentee_accounts(partner, messages, file_format="xlsx"): + """Download mentees assigned to a partner with activity status.""" + if not partner.assign_mentees: + return generate_file( + "partner_mentees", [], get_mentee_columns_with_activity(), file_format + ) + + accts = [] + + for mentee_ref in partner.assign_mentees: + mentee_id = mentee_ref.get("id") + if not mentee_id: + continue + + try: + mentee = MenteeProfile.objects.get(id=mentee_id) + except: + continue + + # Count messages + sent_messages = [ + msg for msg in messages if str(msg.sender_id) == str(mentee.id) + ] + received_messages = [ + msg for msg in messages if str(msg.recipient_id) == str(mentee.id) + ] + + # Determine activity status + total_sent = len(sent_messages) + total_received = len(received_messages) + has_messages = total_sent > 0 or total_received > 0 + + if has_messages: + is_active = "Yes" + activity_reason = f"Sent: {total_sent}, Received: {total_received} messages" + else: + is_active = "No" + activity_reason = "No messages sent or received" + + # Format educations + educations = [] + for edu in mentee.education: + educations.append( + "{0} from {1} ({2})".format( + edu.education_level if edu.education_level else "", + edu.school if edu.school else "", + edu.graduation_year if edu.graduation_year else "", + ) + ) + if mentee.education_level and not educations: + educations.append( + EDUCATION_LEVEL.get(mentee.education_level, mentee.education_level) + ) + + accts.append( + [ + mentee.name, + mentee.gender, + mentee.location, + mentee.age, + mentee.email, + mentee.phone_number, + mentee.image.url if mentee.image else "", + "|".join(educations), + ",".join(mentee.languages) if mentee.languages else "", + "|".join(mentee.specializations) if mentee.specializations else "", + mentee.biography, + partner.organization if partner.organization else "", + "Yes" if mentee.image and mentee.image.url else "No", + "Yes" if mentee.video and mentee.video.url else "No", + 1 if mentee.text_notifications else 0, + 1 if mentee.email_notifications else 0, + 1 if mentee.is_private else 0, + mentee.video.url if mentee.video else "", + ( + ",".join(mentee.favorite_mentors_ids) + if mentee.favorite_mentors_ids + else "" + ), + total_sent, + total_received, + partner.organization if partner.organization else "", + is_active, + activity_reason, + ] + ) + + return generate_file( + "partner_mentees", accts, get_mentee_columns_with_activity(), file_format + ) + + +def get_mentor_columns_with_activity(): + return [ + "mentor Full Name", + "email", + "professional_title", + "linkedin", + "website", + "profile pic up", + "image url", + "video url", + "video(s) up", + "educations", + "languages", + "specializations", + "biography", + "taking_appointments", + "offers_in_person", + "offers_group_appointments", + "text_notifications", + "email_notifications", + "total_received_messages", + "total_sent_messages", + "Affiliated", + "is_active", + "activity_reason", + ] + + +def get_mentee_columns_with_activity(): + return [ + "mentee name", + "gender", + "location", + "age", + "email", + "phone number", + "image url", + "educations", + "languages", + "Areas of interest", + "biography", + "Organization Affiliation", + "profile pic up", + "video(s) up", + "text_notifications", + "email_notifications", + "private account", + "video url", + "favorite_mentor_ids", + "total_sent_messages", + "total_received_messages", + "Affiliated", + "is_active", + "activity_reason", + ] diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 00000000..c0ba0910 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,3 @@ +version = 1 +revision = 3 +requires-python = ">=3.9" diff --git a/frontend/src/components/AdminPartnerData.js b/frontend/src/components/AdminPartnerData.js index 68d84a27..ae2c2b89 100644 --- a/frontend/src/components/AdminPartnerData.js +++ b/frontend/src/components/AdminPartnerData.js @@ -1,11 +1,15 @@ import React, { useEffect, useState } from "react"; -import { fetchAccounts, fetchAccountById } from "utils/api"; +import { + fetchAccounts, + fetchAccountById, + downloadPartnerMentorsData, + downloadPartnerMenteesData, +} from "utils/api"; import Meta from "antd/lib/card/Meta"; import { Table, Input, Dropdown, - Menu, message, Avatar, Layout, @@ -21,12 +25,22 @@ import { Typography, Tag, Button, + Tooltip, + Space, + Segmented, + Badge, } from "antd"; import { UserOutlined, SearchOutlined, DownOutlined, ExclamationCircleOutlined, + DownloadOutlined, + TeamOutlined, + CheckCircleOutlined, + MessageOutlined, + FileExcelOutlined, + FileTextOutlined, } from "@ant-design/icons"; import { HubsDropdown } from "../components/AdminDropdowns"; @@ -138,27 +152,130 @@ export const AdminPartnerData = () => { setModalData(sortedMessages || []); }; - const overlay = ( - - - setOption(options.MENTORS)}>Mentors - - - setOption(options.MENTEES)}>Mentees - - - ); + // Collapsible message list component for the table + const MessageListCell = ({ message_receive_data, record }) => { + const [expanded, setExpanded] = useState(false); + const activeMessages = message_receive_data?.filter( + (item) => item.numberOfMessages > 0 + ); + + if (!activeMessages || activeMessages.length === 0) { + return ( + + No messages + + ); + } + + const displayCount = expanded ? activeMessages.length : 3; + const visibleItems = activeMessages.slice(0, displayCount); + const remainingCount = activeMessages.length - 3; + + return ( +
+ {visibleItems.map((item, index) => ( +
showDetailModal(item, record)} + > + } + src={item.image ? item.image.url : ""} + className={css` + flex-shrink: 0; + `} + /> +
+ + {item.receiver_name} + +
+ +
+ ))} + + {activeMessages.length > 3 && ( + + )} +
+ ); + }; const columns = [ { title: "Logo", dataIndex: "image", key: "image", + width: 60, render: (image) => { return (
} className="modal-profile-icon2" src={image ? image.url : ""} @@ -171,55 +288,37 @@ export const AdminPartnerData = () => { title: "Name", dataIndex: "name", key: "organization", - render: (organization) => {organization}, + width: 180, + render: (organization) => ( + {organization} + ), }, { title: "Email", dataIndex: "email", key: "email", - render: (email) => {email}, + width: 220, + render: (email) => ( + {email} + ), }, { - title: - option.key === ACCOUNT_TYPE.MENTEE - ? "Receiver(Count of Messages)" - : "Sender(Count of Messages)", + title: ( + + + {option.key === ACCOUNT_TYPE.MENTEE + ? "Conversations with Mentors" + : "Conversations with Mentees"} + + ), dataIndex: "message_receive_data", key: "message_receive_data", - render: (message_receive_data, record) => { - return ( - <> - {message_receive_data && - message_receive_data.length > 0 && - message_receive_data.map((item) => { - if (item.numberOfMessages > 0) { - return ( -
- } - className="modal-profile-icon2" - src={item.image ? item.image.url : ""} - /> -
showDetailModal(item, record)} - style={{ - cursor: "pointer", - textDecoration: "underline", - }} - > - {item.receiver_name}  ({item.numberOfMessages} - ) -
-
- ); - } else { - return <>; - } - })} - - ); - }, + render: (message_receive_data, record) => ( + + ), }, ]; useEffect(() => { @@ -511,24 +610,317 @@ export const AdminPartnerData = () => { paddingRight: "2rem", }} > -
- - - {option.text} - - - - setSelectActived(e)} - style={{ marginLeft: "2rem", marginRight: "0.5rem" }} - checked={selectActived} - /> - {"Active"} + {/* Header Controls */} +
+ {/* Left side: View toggle and Active filter */} + + {/* Mentors/Mentees Segmented Control */} +
+ + { + setOption( + value === ACCOUNT_TYPE.MENTOR + ? options.MENTORS + : options.MENTEES + ); + }} + options={[ + { + label: ( + + + Mentees + + ), + value: ACCOUNT_TYPE.MENTEE, + }, + { + label: ( + + + Mentors + + ), + value: ACCOUNT_TYPE.MENTOR, + }, + ]} + /> +
+ + {/* Active Toggle with Tooltip */} + + What is an Active User? +

+ An active user is someone who has exchanged at least one + message with another user (mentor or mentee). This filter + shows only users who have active conversations. +

+
+ } + placement="bottom" + overlayStyle={{ maxWidth: 300 }} + > +
setSelectActived(!selectActived)} + > + setSelectActived(e)} + /> + + + Active Only + + + (?) + +
+ + + + {/* Right side: Export Button */} + {selectedPartner && ( + + + Export Mentees Data + + ), + children: [ + { + key: "mentees-xlsx", + label: ( + + + Excel (.xlsx) + + ), + onClick: () => { + messageApi.loading( + "Preparing mentees Excel export..." + ); + downloadPartnerMenteesData( + selectedPartner._id.$oid, + "xlsx" + ) + .then(() => { + messageApi.success("Mentees Excel downloaded!"); + }) + .catch(() => { + messageApi.error( + "Failed to export mentees data" + ); + }); + }, + }, + { + key: "mentees-csv", + label: ( + + + CSV (.csv) + + ), + onClick: () => { + messageApi.loading( + "Preparing mentees CSV export..." + ); + downloadPartnerMenteesData( + selectedPartner._id.$oid, + "csv" + ) + .then(() => { + messageApi.success("Mentees CSV downloaded!"); + }) + .catch(() => { + messageApi.error( + "Failed to export mentees data" + ); + }); + }, + }, + ], + }, + { + key: "mentors", + label: ( + + + Export Mentors Data + + ), + children: [ + { + key: "mentors-xlsx", + label: ( + + + Excel (.xlsx) + + ), + onClick: () => { + messageApi.loading( + "Preparing mentors Excel export..." + ); + downloadPartnerMentorsData( + selectedPartner._id.$oid, + "xlsx" + ) + .then(() => { + messageApi.success("Mentors Excel downloaded!"); + }) + .catch(() => { + messageApi.error( + "Failed to export mentors data" + ); + }); + }, + }, + { + key: "mentors-csv", + label: ( + + + CSV (.csv) + + ), + onClick: () => { + messageApi.loading( + "Preparing mentors CSV export..." + ); + downloadPartnerMentorsData( + selectedPartner._id.$oid, + "csv" + ) + .then(() => { + messageApi.success("Mentors CSV downloaded!"); + }) + .catch(() => { + messageApi.error( + "Failed to export mentors data" + ); + }); + }, + }, + ], + }, + { + type: "divider", + }, + { + key: "info", + label: ( + + Exports include activity status + + ), + disabled: true, + }, + ], + }} + trigger={["click"]} + > + + + )}
+ + {/* Partner Info Banner */} + {selectedPartner && ( +
+ } + src={selectedPartner.image ? selectedPartner.image.url : null} + /> +
+ + {selectedPartner.name || selectedPartner.organization} + +
+ + Showing {tableData?.length || 0} {option.text.toLowerCase()} + {selectActived ? " with active conversations" : ""} + +
+
+
+ )} + + {/* Table */}
{ `} spinning={subLoading} > - +
record.id?.$oid || record.email} + pagination={{ + pageSize: 10, + showSizeChanger: true, + showTotal: (total, range) => + `${range[0]}-${ + range[1] + } of ${total} ${option.text.toLowerCase()}`, + }} + locale={{ + emptyText: selectedPartner ? ( +
+ +
+ + {selectActived + ? `No active ${option.text.toLowerCase()} found for this partner` + : `No ${option.text.toLowerCase()} assigned to this partner`} + +
+
+ ) : ( +
+ +
+ + Select a partner from the sidebar to view their{" "} + {option.text.toLowerCase()} + +
+
+ ), + }} + /> diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 257adcdc..a7304fe3 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1421,3 +1421,41 @@ export const getGroupParticipants = (hubId) => { } ); }; + +export const downloadPartnerMentorsData = async ( + partnerId, + format = "xlsx" +) => { + const requestExtension = `/download/partner/${partnerId}/accounts`; + let response = await authGet(requestExtension, { + responseType: "blob", + params: { + account_type: ACCOUNT_TYPE.MENTOR, + format: format, + }, + }).catch(console.error); + + if (response) { + const extension = format === "csv" ? "csv" : "xlsx"; + downloadBlob(response, `partner_mentors.${extension}`); + } +}; + +export const downloadPartnerMenteesData = async ( + partnerId, + format = "xlsx" +) => { + const requestExtension = `/download/partner/${partnerId}/accounts`; + let response = await authGet(requestExtension, { + responseType: "blob", + params: { + account_type: ACCOUNT_TYPE.MENTEE, + format: format, + }, + }).catch(console.error); + + if (response) { + const extension = format === "csv" ? "csv" : "xlsx"; + downloadBlob(response, `partner_mentees.${extension}`); + } +};