From d0db3d1323725194a207798f737f27a7ebb6bfb7 Mon Sep 17 00:00:00 2001 From: ismail Date: Tue, 14 Oct 2025 14:01:10 +0300 Subject: [PATCH] update1 --- recruitment/__pycache__/forms.cpython-313.pyc | Bin 22838 -> 23612 bytes .../__pycache__/models.cpython-313.pyc | Bin 46899 -> 47831 bytes .../__pycache__/signals.cpython-313.pyc | Bin 6443 -> 6803 bytes recruitment/__pycache__/urls.cpython-313.pyc | Bin 7982 -> 8954 bytes recruitment/__pycache__/utils.cpython-313.pyc | Bin 17826 -> 18670 bytes recruitment/__pycache__/views.cpython-313.pyc | Bin 50975 -> 68868 bytes recruitment/forms.py | 14 + .../0010_alter_scheduledinterview_schedule.py | 19 + recruitment/models.py | 25 +- recruitment/signals.py | 6 +- recruitment/urls.py | 8 + recruitment/utils.py | 44 +- recruitment/views.py | 536 +++++++++++++++++- templates/includes/meeting_form.html | 150 +++++ .../includes/schedule_interview_div.html | 148 +++++ templates/interviews/schedule_interviews.html | 2 +- .../recruitment/candidate_interview_view.html | 101 +++- .../recruitment/candidate_screening_view.html | 62 +- .../recruitment/schedule_meeting_form.html | 96 ++++ 19 files changed, 1140 insertions(+), 71 deletions(-) create mode 100644 recruitment/migrations/0010_alter_scheduledinterview_schedule.py create mode 100644 templates/includes/meeting_form.html create mode 100644 templates/includes/schedule_interview_div.html create mode 100644 templates/recruitment/schedule_meeting_form.html diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index d0d4ba3a940a071e10b8385d0781d5ebc6aaa2b5..4b8d6957cbe2cbcbd02fd817e33bf803f9289fd1 100644 GIT binary patch delta 535 zcmdnCiE+;kM!wIyyj%=GAfxp*qs?z3p9JHdjp~<~xFi^q7=qQM7=kq>2QbRB>ZNOH zYHj|Z}Ss!S_+Cokj`-Tc+-7c-;E?Mz2t?cf;$j}4Eo}_nnV49Wzc2uaB0Hc!kQI z5G==NzF9w*kCD-CvR#P1strgSL_9_{Ode#IJj5^?uwk!4gc%u~CjSeKm2?1U0ue7j z3@Z=;w$%ZoP(>j)IU_ZtG$++FuOzjoEHky-Ex*V)F)t-Eu_P79%+37BxS7S1o6(L* zkFAJ1n6ro{olBGZmMS5AdcLV8iJ6~3*2x2j$+NuVIExH`-t{X|V4N)PEiUd36b8u` z*MdXkhJa!R>n*{o$@|?@CYO3gPJSOGt?dfZ4g2=x z;+y5Ye=##EO_uWaX3}Jw{LV#obBq5HMn;v=5R(_ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index 99f8d2a259017ab7ec21eba1e0ecb445d4703957..a9e9a069bb70df467e4a67bbd7ca6b89c119efab 100644 GIT binary patch delta 3023 zcmZ`*c~nzp7Jv7J1V|*H5+I=Rf@^TAM;VA)QQWXag@DUZ8e;N*AOz+m$TA{`T13aC z-j0e^nW`hM1M+mnGcIEXPwOu9*s7_Htv-na!#J(1TI6xhUZs4OPiD{I+l0@ zSGM5I6$OXg?Q}WqUQx)kdjz|?Oz=2zMQ3ro=oDOTuP7~bi6tH(yHFDBLUysYSP}(t zAL(@!h$FOS@b!A>W+Q{mmQ(3v>fYUIa%F0-Xy$$TZZ&yJ^MK>b{*oV+avebmQ%D;~ zI>5l^N5MXgSnBe4J!U_tqRY`2+&LOsfhc-i?i`QL;4O68%WT=jl{CtidJl06?2`r( zVK0^^^Lf?9*C(l~3M2m4dWsFm^F9l$;-2>4g2OlFugzcQUZd*Ja8c1WB1gAHj=m8& zwk>k(&FGl=(z?l;dZ`0bIv|+26rkbk3Qnu4G(yQJ%9M?&GXf7dY`m z>UeEA(@F%_@kMGKUk;e1jzHbJy-*w+D}R$#%EJR}A8y3T3<)0M1FWy_+00A{phPFEjcK4v_Wxe-R-xVO&0DEY^?dIQL)&q{?j%*wK8nu%)$u9(^z zo3lp37`&I&;D3yiCjm#*hwBcIOc2Inv;8m}pw6&#@k2;JyTby_xWi$BgLvBU7np$# zXOj9~%y|YLa$bQHEEAic1!J>Ez#LqVJ!8aaVpCB@>ROS}^I)8l7E#|5;&oKe@rQ!0M1><<#$3%0T;1k?jIEs;+D_jegaCT8H zOJ&8>P`q4ZfUWYqqTLYmA@gJVGM3!W<2zD3>+OOxJ?e8(d_i!Z;2y!h8O$eR^J&9JCAZ)_UMfw2PtjPG8TtQb1~-+Nq8<}F4bZ2z z7Zv5Z*vaX&x$Om_l!cee#>1&Sf#p2-P`m6tv}5}+r}`%r(T?*f_QRK(mydzR7`OZ% z@C-YaFZbtZ5`f3z$}#X09;~d>{iwbvSzE>%6}Ty(^KGJYZGd2}+e-kegCdJ&nXk zO(2QxHEB6PDe06>)MOY)&0u`3K0^DPL;@eKTJ+r{&yBatn9b> zj2~3UEnAEL)p%!Xtj0uH#bD4jdnD~EX)J-F98c0jg4bET;aI;dL>teV)ifP*F=S*Y zUfK2rB%-md61HPq-5BVDSL=?$hq!8cGBn`T?fckyP`sm9J(GnUl)v3k4I$HsFp)s% zBmr0KTmX&u+0NRK?vQjX|$lnJwFQU8g}?^j!xzFePF==RzjUSW~KY*n;c#i2=$n z!b|n|@`OeO(pq^NBdA#Q7qZKJNf>u1;kO-puq9KR2 zVfMia%@*>@#;*>#{CksIJK#TS((%Zlba;yV;mK@AOg;Q&f7%DW9wmj+684T@kC-x# zlt=ErcEhB*cstpWOGzQ0POVgH`C|r@RxqWPkcG4Hbg==?|8-qZfHlAx zXi-bSi_~cXj@oa)osD|XVO66^*CUSgPM=kWha3As5Z-S5T|aB+qMk9F)zD=e!;kc{ z>aAfGl@zu}jgJDu0|Tt#7LX!3TSEh^5v*mj8Y4L=(i$m6jeyxcr3xP;;eexPmBA5HR7O>6r6R+|I2%1uxr&p0*(CMypI zg;Ux7%6*`mP?L#CIcb!0Adk9GmT^8w%B(8`D8Z}8`@#nN`gn10stOV|`90<6|1?*F IMSpAgFIse&egFUf delta 2266 zcmZ`*eN>a@70-PjAtVMr2p!KZ{ixLnViD|=`k)->QM$U*)2_9Zc9julJ7+zeJzEc*tw*QRJ@>Vq`cL0Ke!1^+ zzwdMJeKYYL<6pC|YdW1qA)c%1e^;$+e*sE5Hhyz__wgNh{>M}P4iWvh@JNB!zwKnGvB`gu^@BK;ac+tHgafnO> zQ3B%xvSkZVW5kft+eoh;5sad3&0@{4%n@M0-D^5vn9r%UFi6K`wYj?A5Kk>2o=H#Q ztF^OX9ll-bhGUrEnFHNC&tqh&wM@2EU!SHsCq(7fHwA%Ht!6%1|1+k2kIcMJ@Bv18 z7YZXQydCg1e&VeX(eGHM$2?yIwDJmH59m$_x%gmdK8cTEgx@NX&-G`;Owhq!2?7L@ z1fL3cB0iQ{%tWaNw9B)XMVPlf4GtaLww}Qm zeBrsvFo9BQx$-|kV*)R<9=?}arYNNWdp1PE1svHh3ZJ2`twnW{%-#^JaMW&`4PRj5 z#yuhT4go*hn99ClJoNb^p!%2u_oF34j~{N@&2BKPYL9>Tdr85f#tA6>j+zFS^cRwq zt9Fn&iQ#bjuh;>PGPJ$EThijb2xqGJeTCH^GJ(v=41;Ed^iD$G57ES=;cj^D*;CEQhyR`!8J;cqiKc`CCK$mbM}7?@SlVmgdyhOpQ})## z)~mLWY$?_rb*nTadIkS>v>z^F`?00M+Iz=x=1^}3rZv_{)oxFv_^35`B?n1VvVXW#!SyDy=7C?D&G%&HBPdK>N>$_!dX zc@KRJfbXBMiM(S^MjIO`pU*{nl9@pMLgtf-6Z21otDYg!foo1KpY9<^f=krLCLBBI z$Hl{g;Nkxo&V(}gGcK#kqd829^6FShl)M4t8C^qe>6p9-Yn*~EBIRl{eIi5ME&TQ{{!HN!ae{1 diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index 3a25d0c825572cbbd6025b1ad36851be6429a793..61b103c42804167a2245f63c3d30536c3898e748 100644 GIT binary patch delta 703 zcmaKqO>YuG7{_P!tLEv3K$O+!l?x;+Q%_?d<=*fo42he$dLr*@N`OSak|2*@SeQ|-M^uDI4 zh({>$`}R??r5pHNE16d_I@RN-fO2!KgW#>H6owpxsJ|dBpaLoAd9o(t$D=4UG!UI0 zIoiNc&p3gR;~O{=XPlWTHhry68(3yBe16?iGj=&^J8rE}HL<5SR%6?u6>+o$bP8`1 zy!CfDo}z2QGVdWuE37HTg`CVvYLLCc;dEYD6XU4LKU3V?GDT0^waeak%_){DZtbq+ zNsUt7G9?Q4_r~n&tKMz1=_Be@_x0$n~Cx$Yh@PXAwU>lus>7JaD+Ya$K*{uVYFZRa|#Z!zu^Z0^|AX#5nMQ hz8j%vrh}8Gf`D(}zKDe7-awuzNKjedxG2H${{Uu7n|1&I delta 441 zcmbPiy4r~EGcPX}0}$9Wzs`86Hjz()am_^a;5Y?_7=|DjkOV4FVu;~4WrT0SAunZTHj8HnGCilc|PK+#*EgAK>G#PKP6{qHqjq&FYs4`9S2KUrV!k>)M- z{G_bZLe0fF@}26!Cxz5&{vzAVOravCvFL(aC3pq*>j80wSB=3I(w- z3Qu;Bte<>DQcRy8qzdHdB9Qb-h9YqgTNFs#;;_lhPbtkwwJQn)azTMwoG_VBN>beJ iGK1X%PJ!#3QkOWTW>{S2RB2#)$jCW4TFQh4q#XdDkzN@9 diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index fef66752f342a6aaa97c10d85f5b672c66ea2fcd..3221ce3533d423e958894148fba8bbcc42571521 100644 GIT binary patch delta 1720 zcmb7@&rcgi6vw^mO%+_lQ6(Dx*!4pA)g+B^RDoL9Kx|^Sp-R&bvyKBKj#(RHoZ80N zfEVzVQ#tgIYA-#)rQuMeu5z)|3sR*XTB+JOM62=#RJ}wR+C!zNht9kWhExGHEBWi$ z_x-&0=FQlTho48Bf1Ntzu#soI@ND+B_?6SmZacm|_h*YX#dO%Bi&NaKMQXqNM34m`Mg$I?)AXz5pVmP2A^QmkqSxyrxn=cPG-xI~$a9}~1O{Xdk(*DoV z>2fxo3G90JLx}_Og{fXk7PEmPx@fQ0MkJ*Y$F#b0M4KbKXk?mFdcR_G9+;WToh~hA z(@QY(u5iG;V#vG440&Yt+_3h4ySLNHkKMuO>&{Lu@`L}}q`#QVqyxL+{)r@BTw-FT zP&%0U#`(~bz1AOCPboxxT%{2zSE+=lM2P6$y5f!*vS7$(;dkBXKI2iC)9i2D$qnIQ z&2;r4S6@Ry+(+21=tJ%fM$zxPJw#PE@1^9P({`f8XzARPAKlx!*SJX0(~AC`+e@II z-TeCiodIZ|(QiuQNE$aLi-?=YF7VvM+weY&n5{jiwP*VV;w0=*biu<9fZqYRZJTeK zXZz9Fe&h0W#C?K0!RrFAV>4oQNvKOQMr6draJQltJv{0ErKgi<-P1+%Dd=<1HZP5Y zL9<@o2ek&FR&U*DUXsxz*_bLLu7V{+-vaI@Zx6|N=Iwa{=xchAZ?lh*ki=7Cnx`ed zfc0b0r=b7vJ`(o|f&gjv8R*i!+5M<@tG8Z5+=nlL{&kws?X_~;IijPs3C|F zWR|T=&)5AX??=49P7dlSt}tLOTIRdkvgyBq{8x7Jh?DUW$$!fy5bgERU0es91Kj}q z#z%Mk3n+$&Q>5fY(2qsoE|ib4u z@EyyYu-y0R5wkCj`r<}H+q2nDgjhVuD*BHi?f-}9)2taScw{GHh7%~9FlLq!SH%^p zlxn%38e=hYY#xoxW1FqY$SkcFWATbbty)ynkS9#JisUMxLTnPKX%??pRL!DN#!T*S zn=QjEk5VARjIsp3$>sqsuvoM3pdAii)|e|0JjaYtpMDWK&Zg z>g#Jg{CQaSZO#fm4m5gy`(G!1`l}bTtIO>xJD+Ml*80ZV`*uEu-g?a!;puddy9NF& zHr9AN%qIxKzDL6M$i7Em#1I<1U&x{R(xY`6>V;5mMFWy!KEu?+X2CFL#io;v=SQ8V z%5VEml!LS<2R%7^ya1%g>X8MIwOIP*?8a=R(+OxW%vWMKjrb_y)~za<$Uq`vkL7_B zSP#q#klUF{y~*xr;?Fl?u1kSmJHBD4if+W&t<2Pb(I6O zTZeAlzPtpa#5BWvh8geWR-W`#Zmq_BM$LhU;v3_obi@ILh%@LOo?oKwp&mqJUT;(+ z3EK(+PEoD+X4ghnc^$|ZR^)~WrH$*V5^>Y7nQKbOcb%8sC=m@im)y>#rzt8XK}?p% zfef>ffNQ^RpQ#II$NAYom1r+Y|41e2{q^&_8R7M)!F_7 z>UVV2MV*cg)X}KahaFR1+&-}tryWDkF|@npjJfDVIPF5MqSO8v=%2BRw}ISYmw3Dt zRe9LEXe@`$1=kt3O0+EtZCU#q-jzi+XUV0R-4UA2LpE<;Sp{;7m5|;&ypZP?{V%+& B|9AiZ diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index 7c07f6bacd560b2cdd9a4695840b390cfb9abe03..4c58b5f813753fab607be1195bd50d8fa3166732 100644 GIT binary patch delta 2033 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 diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index e73d9b1f7384f254bfff6bed2d3ce7f5a87a0063..eceab8e74ef4014ead39d2e9ed15e0247644ddb4 100644 GIT binary patch delta 23673 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 s$>9^@VWuptTzS0Q*v`@~ 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 W8AmpyDU;K#ZBx>f/calendar/', views.interview_calendar_view, name='interview_calendar'), path('jobs//calendar/interview//', views.interview_detail_view, name='interview_detail'), + + # Candidate Meeting Scheduling/Rescheduling URLs + path('jobs//candidates//schedule-meeting/', views.schedule_candidate_meeting, name='schedule_candidate_meeting'), + path('api/jobs//candidates//schedule-meeting/', views.api_schedule_candidate_meeting, name='api_schedule_candidate_meeting'), + path('jobs//candidates//reschedule-meeting//', views.reschedule_candidate_meeting, name='reschedule_candidate_meeting'), + path('api/jobs//candidates//reschedule-meeting//', views.api_reschedule_candidate_meeting, name='api_reschedule_candidate_meeting'), + # New URL for simple page-based meeting scheduling + path('jobs//candidates//schedule-meeting-page/', views.schedule_meeting_for_candidate, name='schedule_meeting_for_candidate'), ] diff --git a/recruitment/utils.py b/recruitment/utils.py index d96f509..bb51ebf 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -273,7 +273,7 @@ def get_zoom_meeting_details(meeting_id): Returns: dict: A dictionary containing the meeting details or an error message. - The 'start_time' in 'meeting_details' will be a Python datetime object. + Date/datetime fields in 'meeting_details' will be ISO format strings. """ try: access_token = get_access_token() @@ -289,19 +289,26 @@ def get_zoom_meeting_details(meeting_id): if response.status_code == 200: meeting_data = response.json() - if 'start_time' in meeting_data and meeting_data['start_time']: - try: - # Convert ISO 8601 string (with 'Z' for UTC) to datetime object - meeting_data['start_time'] = str(datetime.fromisoformat( - meeting_data['start_time'].replace('Z', '+00:00') - )) - except (ValueError, TypeError) as e: - logger.error( - f"Failed to parse start_time '{meeting_data['start_time']}' for meeting {meeting_id}: {e}" - ) - meeting_data['start_time'] = None # Ensure it's None on failure - else: - meeting_data['start_time'] = None # Explicitly set to None if not present + datetime_fields = [ + 'start_time', 'created_at', 'updated_at', + 'password_changed_at', 'host_join_before_start_time', + 'audio_recording_start', 'recording_files_end' # Add any other known datetime fields + ] + for field_name in datetime_fields: + if field_name in meeting_data and meeting_data[field_name] is not None: + try: + # Convert ISO 8601 string to datetime object, then back to ISO string + # This ensures consistent string format, handling 'Z' for UTC + dt_obj = datetime.fromisoformat(meeting_data[field_name].replace('Z', '+00:00')) + meeting_data[field_name] = dt_obj.isoformat() + except (ValueError, TypeError) as e: + logger.warning( + f"Could not parse or re-serialize datetime field '{field_name}' " + f"for meeting {meeting_id}: {e}. Original value: '{meeting_data[field_name]}'" + ) + # Keep original string if re-serialization fails, or set to None + # meeting_data[field_name] = None + return { "status": "success", "message": "Meeting details retrieved successfully.", @@ -563,3 +570,12 @@ def json_to_markdown_table(data_list): values = [str(row.get(header, "")) for header in headers] markdown += "| " + " | ".join(values) + " |\n" return markdown + + +def get_candidates_from_request(request): + for c in request.POST.items(): + try: + yield models.Candidate.objects.get(pk=c[0]) + except Exception as e: + logger.error(e) + yield None diff --git a/recruitment/views.py b/recruitment/views.py index a297a59..9d30f3b 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -31,6 +31,7 @@ from django.views.generic import CreateView, UpdateView, DetailView, ListView from .utils import ( create_zoom_meeting, delete_zoom_meeting, + get_candidates_from_request, update_zoom_meeting, get_zoom_meeting_details, schedule_interviews, @@ -1122,6 +1123,16 @@ def form_submission_details(request, template_id, slug): def schedule_interviews_view(request, slug): job = get_object_or_404(JobPosting, slug=slug) + # if request.method == "POST" and "Datastar-Request" in request.headers: + # form = InterviewScheduleForm(slug=slug) + # break_formset = BreakTimeFormSet() + # form.initial["candidates"] = get_candidates_from_request(request) + # def response(): + # html = render_to_string("includes/schedule_interview_div.html",{"form": form, "break_formset": break_formset, "job": job}) + # yield SSE.patch_elements(html,"#candidateviewModalBody") + # return DatastarResponse(response()) + + if request.method == "POST": form = InterviewScheduleForm(slug, request.POST) break_formset = BreakTimeFormSet(request.POST) @@ -1340,6 +1351,9 @@ def schedule_interviews_view(request, slug): else: form = InterviewScheduleForm(slug=slug) break_formset = BreakTimeFormSet() + print(request.headers) + if "Hx-Request" in request.headers: + form.initial["candidates"] = [Candidate.objects.get(pk=c[0]) for c in request.GET.items()] return render( request, @@ -1660,13 +1674,7 @@ def candidate_screening_view(request, slug): return render(request, "recruitment/candidate_screening_view.html", context) -def get_candidates_from_request(request): - for c in request.POST.items(): - try: - yield Candidate.objects.get(pk=c[0]) - except Exception as e: - logger.error(e) - yield None + def candidate_exam_view(request, slug): """ Manage candidate tiers and stage transitions @@ -1748,7 +1756,7 @@ def interview_calendar_view(request, slug): scheduled_interviews = ScheduledInterview.objects.filter( job=job ).select_related('candidate', 'zoom_meeting') - print(scheduled_interviews) + # Convert interviews to calendar events events = [] for interview in scheduled_interviews: @@ -1808,3 +1816,515 @@ def interview_detail_view(request, slug, interview_id): } return render(request, 'recruitment/interview_detail.html', context) + +# Candidate Meeting Scheduling/Rescheduling Views +@require_POST +def api_schedule_candidate_meeting(request, job_slug, candidate_pk): + """ + Handle POST request to schedule a Zoom meeting for a candidate via HTMX. + Returns JSON response for modal update. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + topic = f"Interview: {job.title} with {candidate.name}" + start_time_str = request.POST.get('start_time') + duration = int(request.POST.get('duration', 60)) + + if not start_time_str: + return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400) + + try: + # Parse datetime from datetime-local input (YYYY-MM-DDTHH:MM) + # This will be in server's timezone, create_zoom_meeting will handle UTC conversion + naive_start_time = datetime.fromisoformat(start_time_str) + # Ensure it's timezone-aware if your system requires it, or let create_zoom_meeting handle it. + # For simplicity, assuming create_zoom_meeting handles naive datetimes or they are in UTC. + # If start_time is expected to be in a specific timezone, convert it here. + # e.g., start_time = timezone.make_aware(naive_start_time, timezone.get_current_timezone()) + start_time = naive_start_time # Or timezone.make_aware(naive_start_time) + except ValueError: + return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400) + + if start_time <= timezone.now(): + return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + + result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration) + + if result["status"] == "success": + zoom_meeting_details = result["meeting_details"] + zoom_meeting = ZoomMeeting.objects.create( + topic=topic, + start_time=start_time, # Store in local timezone + duration=duration, + meeting_id=zoom_meeting_details["meeting_id"], + join_url=zoom_meeting_details["join_url"], + password=zoom_meeting_details["password"], + # host_email=zoom_meeting_details["host_email"], + status=result["zoom_gateway_response"].get("status", "waiting"), + zoom_gateway_response=result["zoom_gateway_response"], + ) + scheduled_interview = ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + interview_date=start_time.date(), + interview_time=start_time.time(), + status='scheduled' # Or 'confirmed' depending on your workflow + ) + messages.success(request, f"Meeting scheduled with {candidate.name}.") + + # Return updated table row or a success message + # For HTMX, you might want to return a fragment of the updated table + # For now, returning JSON to indicate success and close modal + return JsonResponse({ + 'success': True, + 'message': 'Meeting scheduled successfully!', + 'join_url': zoom_meeting.join_url, + 'meeting_id': zoom_meeting.meeting_id, + 'candidate_name': candidate.name, + 'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M") + }) + else: + messages.error(request, result["message"]) + return JsonResponse({'success': False, 'error': result["message"]}, status=400) + + +def schedule_candidate_meeting(request, job_slug, candidate_pk): + """ + GET: Render modal form to schedule a meeting. (For HTMX) + POST: Handled by api_schedule_candidate_meeting. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + if request.method == "POST": + return api_schedule_candidate_meeting(request, job_slug, candidate_pk) + + # GET request - render the form snippet for HTMX + context = { + 'job': job, + 'candidate': candidate, + 'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}), + 'scheduled_interview': None, # Explicitly None for schedule + } + # Render just the form part, or the whole modal body content + return render(request, "includes/meeting_form.html", context) + + +@require_http_methods(["GET", "POST"]) +def api_schedule_candidate_meeting(request, job_slug, candidate_pk): + """ + Handles GET to render form and POST to process scheduling. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + if request.method == "GET": + # This GET is for HTMX to fetch the form + context = { + 'job': job, + 'candidate': candidate, + 'action_url': reverse('api_schedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk}), + 'scheduled_interview': None, + } + return render(request, "includes/meeting_form.html", context) + + # POST logic (remains the same) + topic = f"Interview: {job.title} with {candidate.name}" + start_time_str = request.POST.get('start_time') + duration = int(request.POST.get('duration', 60)) + + if not start_time_str: + return JsonResponse({'success': False, 'error': 'Start time is required.'}, status=400) + + try: + naive_start_time = datetime.fromisoformat(start_time_str) + start_time = naive_start_time + except ValueError: + return JsonResponse({'success': False, 'error': 'Invalid date/time format for start time.'}, status=400) + + if start_time <= timezone.now(): + return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + + result = create_zoom_meeting(topic=topic, start_time=start_time, duration=duration) + + if result["status"] == "success": + zoom_meeting_details = result["meeting_details"] + zoom_meeting = ZoomMeeting.objects.create( + topic=topic, + start_time=start_time, + duration=duration, + meeting_id=zoom_meeting_details["meeting_id"], + join_url=zoom_meeting_details["join_url"], + password=zoom_meeting_details["password"], + host_email=zoom_meeting_details["host_email"], + status=result["zoom_gateway_response"].get("status", "waiting"), + zoom_gateway_response=result["zoom_gateway_response"], + ) + scheduled_interview = ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting, + interview_date=start_time.date(), + interview_time=start_time.time(), + status='scheduled' + ) + messages.success(request, f"Meeting scheduled with {candidate.name}.") + return JsonResponse({ + 'success': True, + 'message': 'Meeting scheduled successfully!', + 'join_url': zoom_meeting.join_url, + 'meeting_id': zoom_meeting.meeting_id, + 'candidate_name': candidate.name, + 'interview_datetime': start_time.strftime("%Y-%m-%d %H:%M") + }) + else: + messages.error(request, result["message"]) + return JsonResponse({'success': False, 'error': result["message"]}, status=400) + + +@require_http_methods(["GET", "POST"]) +def api_reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): + """ + Handles GET to render form and POST to process rescheduling. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + scheduled_interview = get_object_or_404( + ScheduledInterview.objects.select_related('zoom_meeting'), + pk=interview_pk, + candidate__pk=candidate_pk, + job=job + ) + zoom_meeting = scheduled_interview.zoom_meeting + + if request.method == "GET": + # This GET is for HTMX to fetch the form + initial_data = { + 'topic': zoom_meeting.topic, + 'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'), + 'duration': zoom_meeting.duration, + } + context = { + 'job': job, + 'candidate': scheduled_interview.candidate, + 'scheduled_interview': scheduled_interview, # Pass for conditional logic in template + 'initial_data': initial_data, + 'action_url': reverse('api_reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}) + } + return render(request, "includes/meeting_form.html", context) + + # POST logic (remains the same) + new_start_time_str = request.POST.get('start_time') + new_duration = int(request.POST.get('duration', zoom_meeting.duration)) + + if not new_start_time_str: + return JsonResponse({'success': False, 'error': 'New start time is required.'}, status=400) + + try: + naive_new_start_time = datetime.fromisoformat(new_start_time_str) + new_start_time = naive_new_start_time + except ValueError: + return JsonResponse({'success': False, 'error': 'Invalid date/time format for new start time.'}, status=400) + + if new_start_time <= timezone.now(): + return JsonResponse({'success': False, 'error': 'Start time must be in the future.'}, status=400) + + updated_data = { + "topic": f"Interview: {job.title} with {scheduled_interview.candidate.name}", + "start_time": new_start_time.isoformat() + "Z", + "duration": new_duration, + } + + result = update_zoom_meeting(zoom_meeting.meeting_id, updated_data) + + if result["status"] == "success": + details_result = get_zoom_meeting_details(zoom_meeting.meeting_id) + if details_result["status"] == "success": + updated_zoom_details = details_result["meeting_details"] + zoom_meeting.topic = updated_zoom_details.get("topic", zoom_meeting.topic) + zoom_meeting.start_time = new_start_time + zoom_meeting.duration = new_duration + zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url) + zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password) + zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status) + zoom_meeting.zoom_gateway_response = updated_zoom_details + zoom_meeting.save() + + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.status = 'rescheduled' + scheduled_interview.save() + messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled.") + else: + logger.warning(f"Zoom meeting {zoom_meeting.meeting_id} updated, but failed to fetch latest details.") + zoom_meeting.start_time = new_start_time + zoom_meeting.duration = new_duration + zoom_meeting.save() + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.save() + messages.success(request, f"Meeting for {scheduled_interview.candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)") + + return JsonResponse({ + 'success': True, + 'message': 'Meeting rescheduled successfully!', + 'join_url': zoom_meeting.join_url, + 'new_interview_datetime': new_start_time.strftime("%Y-%m-%d %H:%M") + }) + else: + messages.error(request, result["message"]) + return JsonResponse({'success': False, 'error': result["message"]}, status=400) + +# The original schedule_candidate_meeting and reschedule_candidate_meeting (without api_ prefix) +# can be removed if their only purpose was to be called by the JS onclicks. +# If they were intended for other direct URL access, they can be kept as simple redirects +# or wrappers to the api_ versions. +# For now, let's assume the api_ versions are the primary ones for HTMX. + + +def reschedule_candidate_meeting(request, job_slug, candidate_pk, interview_pk): + """ + Handles GET to display a form for rescheduling a meeting. + Handles POST to process the rescheduling of a meeting. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + scheduled_interview = get_object_or_404( + ScheduledInterview.objects.select_related('zoom_meeting'), + pk=interview_pk, + candidate=candidate, + job=job + ) + zoom_meeting = scheduled_interview.zoom_meeting + + # Determine if the candidate has other future meetings + # This helps in providing context in the template + # Note: This checks for *any* future meetings for the candidate, not just the one being rescheduled. + # If candidate.has_future_meeting is True, it implies they have at least one other upcoming meeting, + # or the specific meeting being rescheduled is itself in the future. + # We can refine this logic if needed, e.g., check for meetings *other than* the current `interview_pk`. + has_other_future_meetings = candidate.has_future_meeting + # More precise check: if the current meeting being rescheduled is in the future, then by definition + # the candidate will have a future meeting (this one). The UI might want to know if there are *others*. + # For now, `candidate.has_future_meeting` is a good general indicator. + + if request.method == "POST": + form = ZoomMeetingForm(request.POST) + if form.is_valid(): + new_topic = form.cleaned_data.get('topic') + new_start_time = form.cleaned_data.get('start_time') + new_duration = form.cleaned_data.get('duration') + + # Use a default topic if not provided, keeping the original structure + if not new_topic: + new_topic = f"Interview: {job.title} with {candidate.name}" + + # Ensure new_start_time is in the future + if new_start_time <= timezone.now(): + messages.error(request, "Start time must be in the future.") + # Re-render form with error and initial data + return render(request, "recruitment/schedule_meeting_form.html", { # Reusing the same form template + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': new_topic, + 'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '', + 'initial_duration': new_duration, + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings # Pass status for template + }) + + # Prepare data for Zoom API update + # The update_zoom_meeting expects start_time as ISO string with 'Z' + zoom_update_data = { + "topic": new_topic, + "start_time": new_start_time.isoformat() + "Z", + "duration": new_duration, + } + + # Update Zoom meeting using utility function + zoom_update_result = update_zoom_meeting(zoom_meeting.meeting_id, zoom_update_data) + + if zoom_update_result["status"] == "success": + # Fetch the latest details from Zoom after successful update + details_result = get_zoom_meeting_details(zoom_meeting.meeting_id) + + if details_result["status"] == "success": + updated_zoom_details = details_result["meeting_details"] + # Update local ZoomMeeting record + zoom_meeting.topic = updated_zoom_details.get("topic", new_topic) + zoom_meeting.start_time = new_start_time # Store the original datetime + zoom_meeting.duration = new_duration + zoom_meeting.join_url = updated_zoom_details.get("join_url", zoom_meeting.join_url) + zoom_meeting.password = updated_zoom_details.get("password", zoom_meeting.password) + zoom_meeting.status = updated_zoom_details.get("status", zoom_meeting.status) + zoom_meeting.zoom_gateway_response = details_result.get("meeting_details") + zoom_meeting.save() + + # Update ScheduledInterview record + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.status = 'rescheduled' # Or 'scheduled' if you prefer + scheduled_interview.save() + messages.success(request, f"Meeting for {candidate.name} rescheduled successfully.") + else: + # If fetching details fails, update with form data and log a warning + logger.warning( + f"Successfully updated Zoom meeting {zoom_meeting.meeting_id}, but failed to fetch updated details. " + f"Error: {details_result.get('message', 'Unknown error')}" + ) + # Update with form data as a fallback + zoom_meeting.topic = new_topic + zoom_meeting.start_time = new_start_time + zoom_meeting.duration = new_duration + zoom_meeting.save() + scheduled_interview.interview_date = new_start_time.date() + scheduled_interview.interview_time = new_start_time.time() + scheduled_interview.save() + messages.success(request, f"Meeting for {candidate.name} rescheduled. (Note: Could not refresh all details from Zoom.)") + + return redirect('candidate_interview_view', slug=job.slug) + else: + messages.error(request, f"Failed to update Zoom meeting: {zoom_update_result['message']}") + # Re-render form with error + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': new_topic, + 'initial_start_time': new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else '', + 'initial_duration': new_duration, + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings + }) + else: + # Form validation errors + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, + 'initial_topic': request.POST.get('topic', new_topic), + 'initial_start_time': request.POST.get('start_time', new_start_time.strftime('%Y-%m-%dT%H:%M') if new_start_time else ''), + 'initial_duration': request.POST.get('duration', new_duration), + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings + }) + else: # GET request + # Pre-populate form with existing meeting details + initial_data = { + 'topic': zoom_meeting.topic, + 'start_time': zoom_meeting.start_time.strftime('%Y-%m-%dT%H:%M'), + 'duration': zoom_meeting.duration, + } + form = ZoomMeetingForm(initial=initial_data) + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'scheduled_interview': scheduled_interview, # Pass to template for title/differentiation + 'action_url': reverse('reschedule_candidate_meeting', kwargs={'job_slug': job_slug, 'candidate_pk': candidate_pk, 'interview_pk': interview_pk}), + 'has_future_meeting': has_other_future_meetings # Pass status for template + }) + + +def schedule_meeting_for_candidate(request, job_slug, candidate_pk): + """ + Handles GET to display a simple form for scheduling a meeting for a candidate. + Handles POST to process the form, create the meeting, and redirect back. + """ + job = get_object_or_404(JobPosting, slug=job_slug) + candidate = get_object_or_404(Candidate, pk=candidate_pk, job=job) + + if request.method == "POST": + form = ZoomMeetingForm(request.POST) + if form.is_valid(): + topic_val = form.cleaned_data.get('topic') + start_time_val = form.cleaned_data.get('start_time') + duration_val = form.cleaned_data.get('duration') + + # Use a default topic if not provided + if not topic_val: + topic_val = f"Interview: {job.title} with {candidate.name}" + + # Ensure start_time is in the future + if start_time_val <= timezone.now(): + messages.error(request, "Start time must be in the future.") + # Re-render form with error and initial data + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'initial_topic': topic_val, + 'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '', + 'initial_duration': duration_val + }) + + # Create Zoom meeting using utility function + # The create_zoom_meeting expects start_time as a datetime object + # and handles its own conversion to UTC for the API call. + zoom_creation_result = create_zoom_meeting( + topic=topic_val, + start_time=start_time_val, # Pass the datetime object + duration=duration_val + ) + + if zoom_creation_result["status"] == "success": + zoom_details = zoom_creation_result["meeting_details"] + zoom_meeting_instance = ZoomMeeting.objects.create( + topic=topic_val, + start_time=start_time_val, # Store the original datetime + duration=duration_val, + meeting_id=zoom_details["meeting_id"], + join_url=zoom_details["join_url"], + password=zoom_details.get("password"), # password might be None + status=zoom_creation_result["zoom_gateway_response"].get("status", "waiting"), + zoom_gateway_response=zoom_creation_result["zoom_gateway_response"], + ) + # Create a ScheduledInterview record + ScheduledInterview.objects.create( + candidate=candidate, + job=job, + zoom_meeting=zoom_meeting_instance, + interview_date=start_time_val.date(), + interview_time=start_time_val.time(), + status='scheduled' + ) + messages.success(request, f"Meeting scheduled with {candidate.name}.") + return redirect('candidate_interview_view', slug=job.slug) + else: + messages.error(request, f"Failed to create Zoom meeting: {zoom_creation_result['message']}") + # Re-render form with error + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'initial_topic': topic_val, + 'initial_start_time': start_time_val.strftime('%Y-%m-%dT%H:%M') if start_time_val else '', + 'initial_duration': duration_val + }) + else: + # Form validation errors + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate, + 'initial_topic': request.POST.get('topic', f"Interview: {job.title} with {candidate.name}"), + 'initial_start_time': request.POST.get('start_time', ''), + 'initial_duration': request.POST.get('duration', 60) + }) + else: # GET request + initial_data = { + 'topic': f"Interview: {job.title} with {candidate.name}", + 'start_time': (timezone.now() + timedelta(hours=1)).strftime('%Y-%m-%dT%H:%M'), # Default to 1 hour from now + 'duration': 60, # Default duration + } + form = ZoomMeetingForm(initial=initial_data) + return render(request, "recruitment/schedule_meeting_form.html", { + 'form': form, + 'job': job, + 'candidate': candidate + }) diff --git a/templates/includes/meeting_form.html b/templates/includes/meeting_form.html new file mode 100644 index 0000000..e568f61 --- /dev/null +++ b/templates/includes/meeting_form.html @@ -0,0 +1,150 @@ + +
+ {% csrf_token %} + + {% if scheduled_interview %} + + {% endif %} + +
+ + +
+ +
+ + +
+ +
+ + +
+ + + + + + + +
+ + +
+
+ +{% block customJS %} + +{% endblock %} diff --git a/templates/includes/schedule_interview_div.html b/templates/includes/schedule_interview_div.html new file mode 100644 index 0000000..d498b46 --- /dev/null +++ b/templates/includes/schedule_interview_div.html @@ -0,0 +1,148 @@ +
+

Schedule Interviews for {{ job.title }}

+ +
+
+
+ {% csrf_token %} +
+
+
Select Candidates
+
+ {{ form.candidates }} +
+
+ +
+
Schedule Details
+ +
+ + {{ form.start_date }} +
+ +
+ + {{ form.end_date }} +
+ +
+ + {{ form.working_days }} +
+ +
+
+
+ + {{ form.start_time }} +
+
+ +
+
+ + {{ form.end_time }} +
+
+
+ +
+
+
+ + {{ form.interview_duration }} +
+
+ +
+
+ + {{ form.buffer_time }} +
+
+
+
+
+ +
+
+
Break Times
+
+ {{ break_formset.management_form }} + {% for form in break_formset %} +
+
+ + {{ form.start_time }} +
+
+ + {{ form.end_time }} +
+
+
+ {{ form.DELETE }} + +
+
+ {% endfor %} +
+ +
+
+ +
+ + Cancel +
+
+
+
+
+ + \ No newline at end of file diff --git a/templates/interviews/schedule_interviews.html b/templates/interviews/schedule_interviews.html index a31fd7f..931be74 100644 --- a/templates/interviews/schedule_interviews.html +++ b/templates/interviews/schedule_interviews.html @@ -2,7 +2,7 @@ {% extends "base.html" %} {% block content %} -
+

Schedule Interviews for {{ job.title }}

diff --git a/templates/recruitment/candidate_interview_view.html b/templates/recruitment/candidate_interview_view.html index 29fc098..2df9259 100644 --- a/templates/recruitment/candidate_interview_view.html +++ b/templates/recruitment/candidate_interview_view.html @@ -214,15 +214,16 @@

{% trans "Candidate Tiers" %}

- {% url "candidate_interview_view" job.slug as bulk_update_candidate_exam_status_url %} + {% url "schedule_interviews" job.slug as bulk_update_candidate_exam_status_url %} {% if candidates %} {% endif %} -
+ @@ -282,14 +283,9 @@ {% endfor %} @@ -299,7 +295,7 @@ -
{{candidate.get_latest_meeting.start_time|date:"m-d-Y h:i A"}} {% include "icons/link.html" %} - + + +
{% if not candidates %} {% endif %}
- +