diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.ar.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.ar.resx new file mode 100644 index 00000000..f87f8101 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.ar.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + الرئيسية + + + التدريبات + + + تدريب جديد + + + إضافة تدريب + + + تعديل التدريب + + + حفظ التدريب + + + اسم التدريب + + + الوصف + + + نص التدريب + + + الحد الأدنى للدرجة + + + تاريخ الاستحقاق + + + المرفقات الحالية + + + إضافة مرفقات + + + المرفقات + + + المعينون حالياً + + + {0} مستخدم(ين) معينون حالياً لهذا التدريب. + + + إضافة جميع الموظفين + + + إضافة مجموعات + + + إضافة أدوار + + + إضافة مستخدمين + + + مجموعات للإضافة + + + أدوار للإضافة + + + مستخدمون للإضافة + + + الأسئلة + + + الأسئلة اختيارية. إذا كنت تريد فقط أن يطلع موظفوك على التدريب، فسيتم تتبعه بدون أسئلة. + + + سؤال + + + الإجابات + + + إضافة سؤال + + + إزالة + + + صحيحة + + + إلغاء + + + التدريبات + + + إضافة تدريب + + + الاسم + + + الوصف + + + تاريخ الاستحقاق + + + إجراء + + + لا يوجد تاريخ استحقاق + + + عرض التدريب + + + التدريب مكتمل + + + تعديل + + + تقرير + + + حذف + + + تحذير: سيتم حذف هذا التدريب نهائياً. هل أنت متأكد أنك تريد حذف التدريب + + + عرض التدريب + + + الحالة: + + + نشط + + + أنشأه: + + + الاستحقاق: + + + تاريخ الإنشاء: + + + مرفقات التدريب + + + فيما يلي الملفات (المرفقات) لهذا التدريب. يرجى تنزيل وفتح ومراجعة جميع هذه المرفقات وكذلك نص التدريب. قد تكون المعلومات الواردة في هذه المرفقات أسئلة في اختبار التدريب. + + + ملفات التدريب + + + اختبار التدريب + + + يحتوي هذا التدريب على اختبار. يرجى مراجعة جميع نصوص التدريب وجميع المرفقات قبل إجراء الاختبار. + + + بدء الاختبار + + + اختبار التدريب + + + التدريب + + + البداية > + + + سؤال + + + إنهاء + + + مرحباً بك في اختبار التدريب ({0}). يرجى اختيار الإجابة الصحيحة لكل سؤال. اضغط على زر التالي لبدء الاختبار. + + + انقر على زر إنهاء أدناه لإرسال إجاباتك على الاختبار. يمكنك العودة للتأكد من إجابتك على جميع الأسئلة التي ترغب فيها. سيتم تقييم اختبارك وسيظهر النتيجة في الصفحة الرئيسية للتدريب. + + + الأول + + + السابق + + + التالي + + + إنهاء + + + تقرير التدريب + + + المجموعة + + + تم العرض + + + مكتمل + + + الدرجة + + + النتيجة + + + لم يتم العرض + + + غير مكتمل + + + لا توجد درجة + + + ناجح + + + راسب + + + مكتمل + + + قيد الانتظار + + + إعادة تعيين المستخدم + + + اختر مجموعات... + + + اختر أدوار... + + + اختر مستخدمين... + + + إزالة هذا السؤال + + + إزالة هذه الإجابة من السؤال + + + إضافة إجابة + + + الإجابة مطلوبة + + + إضافة سؤال + + + نص الإجابة + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.cs b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.cs new file mode 100644 index 00000000..29e731bb --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace Resgrid.Localization.Areas.User.Trainings +{ + public class Trainings + { + } +} diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.de.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.de.resx new file mode 100644 index 00000000..ba09811c --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.de.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Startseite + + + Schulungen + + + Neue Schulung + + + Schulung hinzufügen + + + Schulung bearbeiten + + + Schulung speichern + + + Schulungsname + + + Beschreibung + + + Schulungstext + + + Mindestpunktzahl + + + Fälligkeitsdatum + + + Vorhandene Anhänge + + + Anhänge hinzufügen + + + Anhänge + + + Derzeit zugewiesen + + + {0} Benutzer derzeit dieser Schulung zugewiesen. + + + Gesamtes Personal hinzufügen + + + Gruppen hinzufügen + + + Rollen hinzufügen + + + Benutzer hinzufügen + + + Hinzuzufügende Gruppen + + + Hinzuzufügende Rollen + + + Hinzuzufügende Benutzer + + + Fragen + + + Fragen sind optional. Wenn Sie möchten, dass Ihr Personal die Schulung nur ansieht, wird dies ohne Fragen erfasst. + + + Frage + + + Antworten + + + Frage hinzufügen + + + Entfernen + + + Richtig + + + Abbrechen + + + Schulungen + + + Schulung hinzufügen + + + Name + + + Beschreibung + + + Fälligkeitsdatum + + + Aktion + + + Kein Fälligkeitsdatum + + + Schulung anzeigen + + + Schulung abgeschlossen + + + Bearbeiten + + + Bericht + + + Löschen + + + WARNUNG: Diese Schulung wird dauerhaft gelöscht. Sind Sie sicher, dass Sie die Schulung löschen möchten + + + Schulung anzeigen + + + Status: + + + Aktiv + + + Erstellt von: + + + Fällig am: + + + Erstellt am: + + + Schulungsanhänge + + + Nachfolgend finden Sie die Dateien (Anhänge) für diese Schulung. Bitte laden Sie alle Anhänge herunter, öffnen und überprüfen Sie sie zusammen mit dem Schulungstext. Informationen in diesen Anhängen können Fragen im Schulungsquiz sein. + + + Schulungsdateien + + + Schulungsquiz + + + Diese Schulung hat ein Quiz. Bitte überprüfen Sie den gesamten Schulungstext und alle Anhänge der Schulung, bevor Sie das Quiz ablegen. + + + Quiz starten + + + Schulungsquiz + + + Schulung + + + Start > + + + Frage + + + Abschluss + + + Willkommen zum Quiz für die Schulung ({0}). Bitte wählen Sie die richtige Antwort für jede Frage. Drücken Sie die Schaltfläche Weiter, um das Quiz zu beginnen. + + + Klicken Sie auf die Schaltfläche Abschluss, um Ihre Quizantworten einzureichen. Sie können zurückgehen, um sicherzustellen, dass Sie jede gewünschte Frage beantwortet haben. Ihr Quiz wird bewertet und das Ergebnis wird auf der Schulungs-Startseite angezeigt. + + + Erste + + + Zurück + + + Weiter + + + Abschluss + + + Schulungsbericht + + + Gruppe + + + Angesehen + + + Abgeschlossen + + + Punktzahl + + + Ergebnis + + + Nicht angesehen + + + Nicht abgeschlossen + + + Keine Punktzahl + + + Bestanden + + + Nicht bestanden + + + Abgeschlossen + + + Ausstehend + + + Benutzer zurücksetzen + + + Gruppen auswählen... + + + Rollen auswählen... + + + Benutzer auswählen... + + + Diese Frage entfernen + + + Diese Antwort von der Frage entfernen + + + Antwort hinzufügen + + + Antwort ist erforderlich + + + Frage hinzufügen + + + Antworttext + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.en.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.en.resx new file mode 100644 index 00000000..98955bf8 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.en.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Home + + + Trainings + + + New Training + + + Add Training + + + Edit Training + + + Save Training + + + Training name + + + Description + + + Training Text + + + Minimum Score + + + Due Date + + + Existing Attachments + + + Add Attachments + + + Attachments + + + Currently Assigned + + + {0} user(s) currently assigned to this training. + + + Add all Personnel + + + Add Groups + + + Add Roles + + + Add Users + + + Groups To Add + + + Roles To Add + + + Users To Add + + + Questions + + + Questions are optional, if you just want your personnel to view the training that will be tracked without questions. + + + Question + + + Answers + + + Add Question + + + Remove + + + Correct + + + Cancel + + + Trainings + + + Add Training + + + Name + + + Description + + + Due Date + + + Action + + + No Due Date + + + View Training + + + Training Complete + + + Edit + + + Report + + + Delete + + + WARNING: This will permanently delete this training. Are you sure you want to delete the training + + + View Training + + + Status: + + + Active + + + Created By: + + + Due By: + + + Created On: + + + Training Attachments + + + Below are the files (attachments) for this training. Please download, open and review all of these attachments as well as the training text. Information in these attachments can be questions in the training quiz. + + + Training files + + + Training Quiz + + + This training has a quiz. Please review all the of the training text and all attachments to the training before you take the quiz. + + + Take Quiz + + + Training Quiz + + + Training + + + Start > + + + Question + + + Finish + + + Welcome to the quiz for training ({0}). Please select the correct answer for each question. Press the next button to begin the quiz. + + + Click the finish button below to submit your quiz answers. You can go back to ensure you answered every question you wish. Your quiz will be graded and the result will be shown on the training home page. + + + First + + + Previous + + + Next + + + Finish + + + Training Report + + + Group + + + Viewed + + + Completed + + + Score + + + Result + + + Not Viewed + + + Not Completed + + + No Score + + + Pass + + + Failed + + + Complete + + + Pending + + + Reset User + + + Select groups... + + + Select roles... + + + Select users... + + + Remove this question + + + Remove this answer from the question + + + Add Answer + + + Answer is required + + + Add Question + + + Answer Text + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.es.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.es.resx new file mode 100644 index 00000000..d10fdfd1 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.es.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Inicio + + + Capacitaciones + + + Nueva capacitación + + + Agregar capacitación + + + Editar capacitación + + + Guardar capacitación + + + Nombre de la capacitación + + + Descripción + + + Texto de la capacitación + + + Puntuación mínima + + + Fecha límite + + + Archivos adjuntos existentes + + + Agregar archivos adjuntos + + + Archivos adjuntos + + + Actualmente asignados + + + {0} usuario(s) actualmente asignados a esta capacitación. + + + Agregar todo el personal + + + Agregar grupos + + + Agregar roles + + + Agregar usuarios + + + Grupos a agregar + + + Roles a agregar + + + Usuarios a agregar + + + Preguntas + + + Las preguntas son opcionales, si solo desea que su personal vea la capacitación, se registrará sin preguntas. + + + Pregunta + + + Respuestas + + + Agregar pregunta + + + Eliminar + + + Correcta + + + Cancelar + + + Capacitaciones + + + Agregar capacitación + + + Nombre + + + Descripción + + + Fecha límite + + + Acción + + + Sin fecha límite + + + Ver capacitación + + + Capacitación completada + + + Editar + + + Informe + + + Eliminar + + + ADVERTENCIA: Esto eliminará permanentemente esta capacitación. ¿Está seguro de que desea eliminar la capacitación + + + Ver capacitación + + + Estado: + + + Activo + + + Creado por: + + + Vence el: + + + Creado el: + + + Archivos adjuntos de la capacitación + + + A continuación se encuentran los archivos (adjuntos) de esta capacitación. Descargue, abra y revise todos estos archivos adjuntos así como el texto de la capacitación. La información en estos archivos adjuntos puede ser parte de las preguntas del cuestionario de capacitación. + + + Archivos de la capacitación + + + Cuestionario de capacitación + + + Esta capacitación tiene un cuestionario. Revise todo el texto de la capacitación y todos los archivos adjuntos antes de realizar el cuestionario. + + + Realizar cuestionario + + + Cuestionario de capacitación + + + Capacitación + + + Inicio > + + + Pregunta + + + Finalizar + + + Bienvenido al cuestionario de la capacitación ({0}). Seleccione la respuesta correcta para cada pregunta. Presione el botón siguiente para comenzar el cuestionario. + + + Haga clic en el botón finalizar a continuación para enviar sus respuestas del cuestionario. Puede volver atrás para asegurarse de haber respondido todas las preguntas que desee. Su cuestionario será calificado y el resultado se mostrará en la página principal de capacitaciones. + + + Primero + + + Anterior + + + Siguiente + + + Finalizar + + + Informe de capacitación + + + Grupo + + + Visto + + + Completado + + + Puntuación + + + Resultado + + + No visto + + + No completado + + + Sin puntuación + + + Aprobado + + + Reprobado + + + Completo + + + Pendiente + + + Restablecer usuario + + + Seleccionar grupos... + + + Seleccionar roles... + + + Seleccionar usuarios... + + + Eliminar esta pregunta + + + Eliminar esta respuesta de la pregunta + + + Agregar respuesta + + + La respuesta es obligatoria + + + Agregar pregunta + + + Texto de respuesta + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.fr.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.fr.resx new file mode 100644 index 00000000..32df5050 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.fr.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Accueil + + + Formations + + + Nouvelle formation + + + Ajouter une formation + + + Modifier la formation + + + Enregistrer la formation + + + Nom de la formation + + + Description + + + Texte de la formation + + + Score minimum + + + Date limite + + + Pièces jointes existantes + + + Ajouter des pièces jointes + + + Pièces jointes + + + Actuellement assignés + + + {0} utilisateur(s) actuellement assigné(s) à cette formation. + + + Ajouter tout le personnel + + + Ajouter des groupes + + + Ajouter des rôles + + + Ajouter des utilisateurs + + + Groupes à ajouter + + + Rôles à ajouter + + + Utilisateurs à ajouter + + + Questions + + + Les questions sont facultatives. Si vous souhaitez simplement que votre personnel consulte la formation, celle-ci sera suivie sans questions. + + + Question + + + Réponses + + + Ajouter une question + + + Supprimer + + + Correcte + + + Annuler + + + Formations + + + Ajouter une formation + + + Nom + + + Description + + + Date limite + + + Action + + + Pas de date limite + + + Voir la formation + + + Formation terminée + + + Modifier + + + Rapport + + + Supprimer + + + AVERTISSEMENT : Cette action supprimera définitivement cette formation. Êtes-vous sûr de vouloir supprimer la formation + + + Voir la formation + + + Statut : + + + Actif + + + Créé par : + + + Échéance : + + + Créé le : + + + Pièces jointes de la formation + + + Ci-dessous se trouvent les fichiers (pièces jointes) de cette formation. Veuillez télécharger, ouvrir et examiner toutes ces pièces jointes ainsi que le texte de la formation. Les informations contenues dans ces pièces jointes peuvent faire l'objet de questions dans le quiz de formation. + + + Fichiers de formation + + + Quiz de formation + + + Cette formation comporte un quiz. Veuillez examiner l'ensemble du texte de la formation et toutes les pièces jointes avant de passer le quiz. + + + Passer le quiz + + + Quiz de formation + + + Formation + + + Début > + + + Question + + + Terminer + + + Bienvenue au quiz de la formation ({0}). Veuillez sélectionner la bonne réponse pour chaque question. Appuyez sur le bouton suivant pour commencer le quiz. + + + Cliquez sur le bouton Terminer ci-dessous pour soumettre vos réponses au quiz. Vous pouvez revenir en arrière pour vous assurer d'avoir répondu à toutes les questions souhaitées. Votre quiz sera noté et le résultat sera affiché sur la page d'accueil des formations. + + + Premier + + + Précédent + + + Suivant + + + Terminer + + + Rapport de formation + + + Groupe + + + Consulté + + + Terminé + + + Score + + + Résultat + + + Non consulté + + + Non terminé + + + Aucun score + + + Réussi + + + Échoué + + + Terminé + + + En attente + + + Réinitialiser l'utilisateur + + + Sélectionner des groupes... + + + Sélectionner des rôles... + + + Sélectionner des utilisateurs... + + + Supprimer cette question + + + Supprimer cette réponse de la question + + + Ajouter une réponse + + + La réponse est obligatoire + + + Ajouter une question + + + Texte de réponse + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.it.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.it.resx new file mode 100644 index 00000000..6feb2b69 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.it.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Home + + + Formazioni + + + Nuova formazione + + + Aggiungi formazione + + + Modifica formazione + + + Salva formazione + + + Nome della formazione + + + Descrizione + + + Testo della formazione + + + Punteggio minimo + + + Data di scadenza + + + Allegati esistenti + + + Aggiungi allegati + + + Allegati + + + Attualmente assegnati + + + {0} utente/i attualmente assegnato/i a questa formazione. + + + Aggiungi tutto il personale + + + Aggiungi gruppi + + + Aggiungi ruoli + + + Aggiungi utenti + + + Gruppi da aggiungere + + + Ruoli da aggiungere + + + Utenti da aggiungere + + + Domande + + + Le domande sono facoltative. Se desideri che il tuo personale visualizzi solo la formazione, questa verrà tracciata senza domande. + + + Domanda + + + Risposte + + + Aggiungi domanda + + + Rimuovi + + + Corretta + + + Annulla + + + Formazioni + + + Aggiungi formazione + + + Nome + + + Descrizione + + + Data di scadenza + + + Azione + + + Nessuna data di scadenza + + + Visualizza formazione + + + Formazione completata + + + Modifica + + + Rapporto + + + Elimina + + + ATTENZIONE: Questa azione eliminerà permanentemente questa formazione. Sei sicuro di voler eliminare la formazione + + + Visualizza formazione + + + Stato: + + + Attivo + + + Creato da: + + + Scadenza: + + + Creato il: + + + Allegati della formazione + + + Di seguito sono riportati i file (allegati) per questa formazione. Scarica, apri e rivedi tutti questi allegati e il testo della formazione. Le informazioni contenute in questi allegati possono essere oggetto di domande nel quiz della formazione. + + + File della formazione + + + Quiz della formazione + + + Questa formazione prevede un quiz. Rivedi tutto il testo della formazione e tutti gli allegati prima di sostenere il quiz. + + + Inizia il quiz + + + Quiz della formazione + + + Formazione + + + Inizio > + + + Domanda + + + Fine + + + Benvenuto al quiz della formazione ({0}). Seleziona la risposta corretta per ogni domanda. Premi il pulsante avanti per iniziare il quiz. + + + Fai clic sul pulsante Fine qui sotto per inviare le tue risposte al quiz. Puoi tornare indietro per assicurarti di aver risposto a tutte le domande desiderate. Il quiz verrà valutato e il risultato verrà mostrato nella pagina principale delle formazioni. + + + Primo + + + Precedente + + + Successivo + + + Fine + + + Rapporto della formazione + + + Gruppo + + + Visualizzato + + + Completato + + + Punteggio + + + Risultato + + + Non visualizzato + + + Non completato + + + Nessun punteggio + + + Superato + + + Non superato + + + Completato + + + In attesa + + + Reimposta utente + + + Seleziona gruppi... + + + Seleziona ruoli... + + + Seleziona utenti... + + + Rimuovi questa domanda + + + Rimuovi questa risposta dalla domanda + + + Aggiungi risposta + + + La risposta è obbligatoria + + + Aggiungi domanda + + + Testo della risposta + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.pl.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.pl.resx new file mode 100644 index 00000000..acbe4514 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.pl.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Strona główna + + + Szkolenia + + + Nowe szkolenie + + + Dodaj szkolenie + + + Edytuj szkolenie + + + Zapisz szkolenie + + + Nazwa szkolenia + + + Opis + + + Tekst szkolenia + + + Minimalny wynik + + + Termin + + + Istniejące załączniki + + + Dodaj załączniki + + + Załączniki + + + Aktualnie przypisani + + + {0} użytkowników aktualnie przypisanych do tego szkolenia. + + + Dodaj cały personel + + + Dodaj grupy + + + Dodaj role + + + Dodaj użytkowników + + + Grupy do dodania + + + Role do dodania + + + Użytkownicy do dodania + + + Pytania + + + Pytania są opcjonalne. Jeśli chcesz, aby personel tylko zapoznał się ze szkoleniem, będzie ono śledzone bez pytań. + + + Pytanie + + + Odpowiedzi + + + Dodaj pytanie + + + Usuń + + + Poprawna + + + Anuluj + + + Szkolenia + + + Dodaj szkolenie + + + Nazwa + + + Opis + + + Termin + + + Akcja + + + Brak terminu + + + Wyświetl szkolenie + + + Szkolenie ukończone + + + Edytuj + + + Raport + + + Usuń + + + OSTRZEŻENIE: To trwale usunie to szkolenie. Czy na pewno chcesz usunąć szkolenie + + + Wyświetl szkolenie + + + Status: + + + Aktywne + + + Utworzone przez: + + + Termin do: + + + Utworzono: + + + Załączniki szkolenia + + + Poniżej znajdują się pliki (załączniki) do tego szkolenia. Pobierz, otwórz i przejrzyj wszystkie załączniki oraz tekst szkolenia. Informacje zawarte w tych załącznikach mogą być pytaniami w quizie szkoleniowym. + + + Pliki szkolenia + + + Quiz szkoleniowy + + + To szkolenie zawiera quiz. Przejrzyj cały tekst szkolenia i wszystkie załączniki przed przystąpieniem do quizu. + + + Rozpocznij quiz + + + Quiz szkoleniowy + + + Szkolenie + + + Start > + + + Pytanie + + + Zakończ + + + Witaj w quizie do szkolenia ({0}). Wybierz poprawną odpowiedź na każde pytanie. Naciśnij przycisk dalej, aby rozpocząć quiz. + + + Kliknij przycisk Zakończ poniżej, aby przesłać odpowiedzi quizu. Możesz wrócić, aby upewnić się, że odpowiedziałeś na wszystkie pytania. Quiz zostanie oceniony, a wynik zostanie wyświetlony na stronie głównej szkoleń. + + + Pierwszy + + + Poprzedni + + + Następny + + + Zakończ + + + Raport szkolenia + + + Grupa + + + Wyświetlone + + + Ukończone + + + Wynik + + + Rezultat + + + Niewyświetlone + + + Nieukończone + + + Brak wyniku + + + Zdane + + + Niezdane + + + Ukończone + + + Oczekujące + + + Zresetuj użytkownika + + + Wybierz grupy... + + + Wybierz role... + + + Wybierz użytkowników... + + + Usuń to pytanie + + + Usuń tę odpowiedź z pytania + + + Dodaj odpowiedź + + + Odpowiedź jest wymagana + + + Dodaj pytanie + + + Tekst odpowiedzi + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.sv.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.sv.resx new file mode 100644 index 00000000..063a50e6 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.sv.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Hem + + + Utbildningar + + + Ny utbildning + + + Lägg till utbildning + + + Redigera utbildning + + + Spara utbildning + + + Utbildningsnamn + + + Beskrivning + + + Utbildningstext + + + Minimipoäng + + + Förfallodatum + + + Befintliga bilagor + + + Lägg till bilagor + + + Bilagor + + + Nuvarande tilldelade + + + {0} användare är för närvarande tilldelade denna utbildning. + + + Lägg till all personal + + + Lägg till grupper + + + Lägg till roller + + + Lägg till användare + + + Grupper att lägga till + + + Roller att lägga till + + + Användare att lägga till + + + Frågor + + + Frågor är valfria. Om du bara vill att din personal ska se utbildningen kommer den att spåras utan frågor. + + + Fråga + + + Svar + + + Lägg till fråga + + + Ta bort + + + Korrekt + + + Avbryt + + + Utbildningar + + + Lägg till utbildning + + + Namn + + + Beskrivning + + + Förfallodatum + + + Åtgärd + + + Inget förfallodatum + + + Visa utbildning + + + Utbildning slutförd + + + Redigera + + + Rapport + + + Ta bort + + + VARNING: Detta kommer att permanent ta bort denna utbildning. Är du säker på att du vill ta bort utbildningen + + + Visa utbildning + + + Status: + + + Aktiv + + + Skapad av: + + + Förfaller: + + + Skapad den: + + + Utbildningsbilagor + + + Nedan finns filerna (bilagorna) för denna utbildning. Ladda ner, öppna och granska alla dessa bilagor samt utbildningstexten. Information i dessa bilagor kan vara frågor i utbildningsquizet. + + + Utbildningsfiler + + + Utbildningsquiz + + + Denna utbildning har ett quiz. Granska all utbildningstext och alla bilagor till utbildningen innan du gör quizet. + + + Starta quiz + + + Utbildningsquiz + + + Utbildning + + + Start > + + + Fråga + + + Avsluta + + + Välkommen till quizet för utbildningen ({0}). Välj rätt svar för varje fråga. Tryck på nästa-knappen för att börja quizet. + + + Klicka på knappen Avsluta nedan för att skicka in dina quizsvar. Du kan gå tillbaka för att säkerställa att du har besvarat alla frågor du önskar. Ditt quiz kommer att bedömas och resultatet visas på utbildningens startsida. + + + Första + + + Föregående + + + Nästa + + + Avsluta + + + Utbildningsrapport + + + Grupp + + + Visad + + + Slutförd + + + Poäng + + + Resultat + + + Ej visad + + + Ej slutförd + + + Ingen poäng + + + Godkänd + + + Underkänd + + + Slutförd + + + Väntande + + + Återställ användare + + + Välj grupper... + + + Välj roller... + + + Välj användare... + + + Ta bort denna fråga + + + Ta bort detta svar från frågan + + + Lägg till svar + + + Svar krävs + + + Lägg till fråga + + + Svarstext + + diff --git a/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.uk.resx b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.uk.resx new file mode 100644 index 00000000..019ad813 --- /dev/null +++ b/Core/Resgrid.Localization/Areas/User/Trainings/Trainings.uk.resx @@ -0,0 +1,390 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Головна + + + Навчання + + + Нове навчання + + + Додати навчання + + + Редагувати навчання + + + Зберегти навчання + + + Назва навчання + + + Опис + + + Текст навчання + + + Мінімальний бал + + + Термін виконання + + + Існуючі вкладення + + + Додати вкладення + + + Вкладення + + + Наразі призначені + + + {0} користувач(ів) наразі призначено до цього навчання. + + + Додати весь персонал + + + Додати групи + + + Додати ролі + + + Додати користувачів + + + Групи для додавання + + + Ролі для додавання + + + Користувачі для додавання + + + Запитання + + + Запитання є необов’язковими. Якщо ви просто хочете, щоб персонал переглянув навчання, це буде відстежуватися без запитань. + + + Запитання + + + Відповіді + + + Додати запитання + + + Видалити + + + Правильна + + + Скасувати + + + Навчання + + + Додати навчання + + + Назва + + + Опис + + + Термін виконання + + + Дія + + + Без терміну + + + Переглянути навчання + + + Навчання завершено + + + Редагувати + + + Звіт + + + Видалити + + + УВАГА: Це назавжди видалить це навчання. Ви впевнені, що хочете видалити навчання + + + Переглянути навчання + + + Статус: + + + Активне + + + Створено: + + + Термін до: + + + Дата створення: + + + Вкладення навчання + + + Нижче наведено файли (вкладення) для цього навчання. Завантажте, відкрийте та перегляньте всі ці вкладення та текст навчання. Інформація в цих вкладеннях може бути запитаннями в тесті навчання. + + + Файли навчання + + + Тест навчання + + + Це навчання має тест. Перегляньте весь текст навчання та всі вкладення перед проходженням тесту. + + + Пройти тест + + + Тест навчання + + + Навчання + + + Початок > + + + Запитання + + + Завершити + + + Ласкаво просимо до тесту з навчання ({0}). Виберіть правильну відповідь на кожне запитання. Натисніть кнопку далі, щоб розпочати тест. + + + Натисніть кнопку Завершити нижче, щоб надіслати відповіді на тест. Ви можете повернутися, щоб переконатися, що ви відповіли на всі бажані запитання. Ваш тест буде оцінено, а результат буде показано на головній сторінці навчання. + + + Перший + + + Попередній + + + Наступний + + + Завершити + + + Звіт про навчання + + + Група + + + Переглянуто + + + Завершено + + + Бал + + + Результат + + + Не переглянуто + + + Не завершено + + + Без оцінки + + + Здано + + + Не здано + + + Завершено + + + Очікується + + + Скинути користувача + + + Виберіть групи... + + + Виберіть ролі... + + + Виберіть користувачів... + + + Видалити це запитання + + + Видалити цю відповідь із запитання + + + Додати відповідь + + + Відповідь є обов’язковою + + + Додати запитання + + + Текст відповіді + + diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 19bdfac3..19fd32e4 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -411,7 +411,7 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st try { var payment = await _subscriptionsService.GetCurrentPaymentForDepartmentAsync(departmentId); - await _smsService.SendCancelCallAsync(call, dispatch, departmentNumber, departmentId, profile, call.Address, payment); + await _smsService.SendCancelCallAsync(call, dispatch, departmentNumber, departmentId, profile, address ?? call.Address, payment); } catch (Exception ex) { diff --git a/Core/Resgrid.Services/EmailService.cs b/Core/Resgrid.Services/EmailService.cs index 3271c076..612c463f 100644 --- a/Core/Resgrid.Services/EmailService.cs +++ b/Core/Resgrid.Services/EmailService.cs @@ -234,7 +234,7 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, UserProf if (String.IsNullOrWhiteSpace(protocols)) protocols = protocol.Data; else - protocols = protocol + "," + protocol.Data; + protocols = protocols + "," + protocol.Data; } if (!String.IsNullOrWhiteSpace(protocols)) @@ -311,7 +311,7 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, Us if (String.IsNullOrWhiteSpace(protocols)) protocols = protocol.Data; else - protocols = protocol + "," + protocol.Data; + protocols = protocols + "," + protocol.Data; } if (!String.IsNullOrWhiteSpace(protocols)) diff --git a/Core/Resgrid.Services/SmsService.cs b/Core/Resgrid.Services/SmsService.cs index e61418b4..e31f0581 100644 --- a/Core/Resgrid.Services/SmsService.cs +++ b/Core/Resgrid.Services/SmsService.cs @@ -181,7 +181,7 @@ public async Task SendCallAsync(Call call, CallDispatch dispatch, string d if (String.IsNullOrWhiteSpace(protocols)) protocols = protocol.Data; else - protocols = protocol + "," + protocol.Data; + protocols = protocols + "," + protocol.Data; } } @@ -261,7 +261,7 @@ public async Task SendCancelCallAsync(Call call, CallDispatch dispatch, st if (String.IsNullOrWhiteSpace(protocols)) protocols = protocol.Data; else - protocols = protocol + "," + protocol.Data; + protocols = protocols + "," + protocol.Data; } } diff --git a/Core/Resgrid.Services/TrainingService.cs b/Core/Resgrid.Services/TrainingService.cs index b669e082..11366a01 100644 --- a/Core/Resgrid.Services/TrainingService.cs +++ b/Core/Resgrid.Services/TrainingService.cs @@ -92,6 +92,10 @@ public async Task> GetAllTrainingsForDepartmentAsync(int departme public async Task GetTrainingByIdAsync(int trainingId) { var training = await _trainingRepository.GetTrainingByTrainingIdAsync(trainingId); + + if (training == null) + return null; + training.Questions = (await _trainingQuestionRepository.GetTrainingQuestionsByTrainingIdAsync(training.TrainingId)).ToList(); training.Attachments = (await _trainingAttachmentRepository.GetTrainingAttachmentsByTrainingIdAsync(trainingId)).ToList(); diff --git a/Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs b/Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs index 2d415674..0e8a9a75 100644 --- a/Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs +++ b/Providers/Resgrid.Providers.Weather/EnvironmentCanadaWeatherAlertProvider.cs @@ -1,7 +1,12 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; +using System.Xml.Linq; using Resgrid.Model; using Resgrid.Model.Providers; @@ -9,6 +14,18 @@ namespace Resgrid.Providers.Weather { public class EnvironmentCanadaWeatherAlertProvider : IWeatherAlertProvider { + private static readonly HttpClient _httpClient = new HttpClient(); + private const string DefaultBaseUrl = "https://dd.weather.gc.ca/alerts/cap"; + + // CAP XML namespace + private static readonly XNamespace Cap = "urn:oasis:names:tc:emergency:cap:1.2"; + + static EnvironmentCanadaWeatherAlertProvider() + { + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml")); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + } + public WeatherAlertSourceType SourceType => WeatherAlertSourceType.EnvironmentCanada; public async Task> FetchAlertsAsync(WeatherAlertSource source, CancellationToken ct = default) @@ -16,17 +33,367 @@ public async Task> FetchAlertsAsync(WeatherAlertSource source // Check shared cache first if (WeatherAlertResponseCache.TryGet(SourceType, source.AreaFilter, out var cachedAlerts)) { - return cachedAlerts; + return CloneAlertsForSource(cachedAlerts, source); } - // TODO: Implement CAP XML parsing from Environment Canada - // Endpoint: https://dd.weather.gc.ca/alerts/cap/ var alerts = new List(); + var baseUrl = !string.IsNullOrEmpty(source.CustomEndpoint) ? source.CustomEndpoint : DefaultBaseUrl; + + // Environment Canada organizes alerts by date and province + // AreaFilter is expected to be a province code (e.g., "ON", "BC", "AB") or comma-separated list + var provinces = ParseAreaFilter(source.AreaFilter); + + // Build the feed URL - Environment Canada provides an ATOM feed at /alerts/cap/{YYYYMMDD}/{province}/ + var today = DateTime.UtcNow.ToString("yyyyMMdd"); + var feedUrls = new List(); + + if (provinces.Length > 0) + { + foreach (var province in provinces) + { + feedUrls.Add($"{baseUrl}/{today}/{province.ToUpperInvariant()}/"); + } + } + else + { + // If no area filter, fetch the top-level ATOM feed + feedUrls.Add($"{baseUrl}/{today}/"); + } + + foreach (var feedUrl in feedUrls) + { + try + { + var capUrls = await FetchCapUrlsFromFeedAsync(feedUrl, ct); + + foreach (var capUrl in capUrls) + { + try + { + var capAlerts = await FetchAndParseCapDocumentAsync(capUrl, source, ct); + alerts.AddRange(capAlerts); + } + catch (Exception) + { + // Skip malformed CAP documents + continue; + } + } + } + catch (Exception) + { + // Skip feed errors, continue with other provinces + continue; + } + } + + // Deduplicate by ExternalId + alerts = alerts + .GroupBy(a => a.ExternalId) + .Select(g => g.First()) + .ToList(); // Store in shared cache WeatherAlertResponseCache.Set(SourceType, source.AreaFilter, alerts); return alerts; } + + private async Task> FetchCapUrlsFromFeedAsync(string feedUrl, CancellationToken ct) + { + var capUrls = new List(); + + using var request = new HttpRequestMessage(HttpMethod.Get, feedUrl); + request.Headers.UserAgent.ParseAdd("Resgrid/1.0 (weather-alerts)"); + + using var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(); + + // Environment Canada directory listing contains links to CAP XML files + // Parse as HTML-like content looking for .cap file links + var lines = content.Split('\n'); + foreach (var line in lines) + { + // Look for href links pointing to .cap files + var hrefStart = line.IndexOf("href=\"", StringComparison.OrdinalIgnoreCase); + if (hrefStart < 0) continue; + + hrefStart += 6; + var hrefEnd = line.IndexOf("\"", hrefStart, StringComparison.Ordinal); + if (hrefEnd < 0) continue; + + var href = line.Substring(hrefStart, hrefEnd - hrefStart); + if (href.EndsWith(".cap", StringComparison.OrdinalIgnoreCase)) + { + // Build absolute URL + var absoluteUrl = href.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? href + : feedUrl.TrimEnd('/') + "/" + href.TrimStart('/'); + + capUrls.Add(absoluteUrl); + } + } + + return capUrls; + } + + private async Task> FetchAndParseCapDocumentAsync(string capUrl, WeatherAlertSource source, CancellationToken ct) + { + var alerts = new List(); + + using var request = new HttpRequestMessage(HttpMethod.Get, capUrl); + request.Headers.UserAgent.ParseAdd("Resgrid/1.0 (weather-alerts)"); + + using var response = await _httpClient.SendAsync(request, ct); + response.EnsureSuccessStatusCode(); + + var xml = await response.Content.ReadAsStringAsync(); + var doc = XDocument.Parse(xml); + var root = doc.Root; + + if (root == null) + return alerts; + + var identifier = root.Element(Cap + "identifier")?.Value; + var sender = root.Element(Cap + "sender")?.Value; + var sent = ParseCapDateTime(root.Element(Cap + "sent")?.Value); + var status = root.Element(Cap + "status")?.Value; + + // Skip test and draft messages + if (string.Equals(status, "Test", StringComparison.OrdinalIgnoreCase) || + string.Equals(status, "Draft", StringComparison.OrdinalIgnoreCase)) + return alerts; + + var references = root.Element(Cap + "references")?.Value; + + // Process each block + foreach (var info in root.Elements(Cap + "info")) + { + // Prefer English language block; skip French duplicates + var language = info.Element(Cap + "language")?.Value ?? "en-CA"; + if (!language.StartsWith("en", StringComparison.OrdinalIgnoreCase)) + continue; + + var alert = new WeatherAlert + { + DepartmentId = source.DepartmentId, + WeatherAlertSourceId = source.WeatherAlertSourceId, + ExternalId = identifier, + Sender = sender ?? info.Element(Cap + "senderName")?.Value, + Event = info.Element(Cap + "event")?.Value, + AlertCategory = MapCategory(info.Element(Cap + "category")?.Value), + Severity = (int)MapSeverity(info.Element(Cap + "severity")?.Value), + Urgency = (int)MapUrgency(info.Element(Cap + "urgency")?.Value), + Certainty = (int)MapCertainty(info.Element(Cap + "certainty")?.Value), + Status = MapStatus(root.Element(Cap + "msgType")?.Value), + Headline = info.Element(Cap + "headline")?.Value, + Description = info.Element(Cap + "description")?.Value, + Instruction = info.Element(Cap + "instruction")?.Value, + EffectiveUtc = ParseCapDateTime(info.Element(Cap + "effective")?.Value) ?? sent ?? DateTime.UtcNow, + OnsetUtc = ParseCapDateTime(info.Element(Cap + "onset")?.Value), + ExpiresUtc = ParseCapDateTime(info.Element(Cap + "expires")?.Value), + SentUtc = sent, + FirstSeenUtc = DateTime.UtcNow, + LastUpdatedUtc = DateTime.UtcNow, + NotificationSent = false, + ReferencesExternalId = references + }; + + // Process blocks + var areas = info.Elements(Cap + "area").ToList(); + if (areas.Any()) + { + alert.AreaDescription = string.Join("; ", areas.Select(a => a.Element(Cap + "areaDesc")?.Value).Where(v => v != null)); + + // Extract polygon + var polygon = areas + .SelectMany(a => a.Elements(Cap + "polygon")) + .Select(p => p.Value) + .FirstOrDefault(); + + if (!string.IsNullOrEmpty(polygon)) + { + alert.Polygon = polygon; + alert.CenterGeoLocation = ComputeCenterFromCapPolygon(polygon); + } + + // Extract geocodes + var geocodes = areas + .SelectMany(a => a.Elements(Cap + "geocode")) + .Select(g => new + { + Name = g.Element(Cap + "valueName")?.Value, + Value = g.Element(Cap + "value")?.Value + }) + .Where(g => g.Name != null && g.Value != null) + .ToList(); + + if (geocodes.Any()) + { + alert.Geocodes = System.Text.Json.JsonSerializer.Serialize( + geocodes.GroupBy(g => g.Name).ToDictionary(g => g.Key, g => g.Last().Value)); + } + } + + alerts.Add(alert); + } + + return alerts; + } + + private static DateTime? ParseCapDateTime(string value) + { + if (string.IsNullOrEmpty(value)) + return null; + + if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto)) + return dto.UtcDateTime; + + return null; + } + + private static string ComputeCenterFromCapPolygon(string polygon) + { + // CAP polygon format: "lat,lng lat,lng lat,lng ..." + try + { + var points = polygon.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + double totalLat = 0, totalLng = 0; + int count = 0; + + foreach (var point in points) + { + var coords = point.Split(','); + if (coords.Length >= 2 && + double.TryParse(coords[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var lat) && + double.TryParse(coords[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var lng)) + { + totalLat += lat; + totalLng += lng; + count++; + } + } + + if (count > 0) + return $"{(totalLat / count).ToString(CultureInfo.InvariantCulture)},{(totalLng / count).ToString(CultureInfo.InvariantCulture)}"; + } + catch { } + + return null; + } + + private static List CloneAlertsForSource(List cachedAlerts, WeatherAlertSource source) + { + return cachedAlerts.Select(a => new WeatherAlert + { + DepartmentId = source.DepartmentId, + WeatherAlertSourceId = source.WeatherAlertSourceId, + ExternalId = a.ExternalId, + Sender = a.Sender, + Event = a.Event, + AlertCategory = a.AlertCategory, + Severity = a.Severity, + Urgency = a.Urgency, + Certainty = a.Certainty, + Status = a.Status, + Headline = a.Headline, + Description = a.Description, + Instruction = a.Instruction, + AreaDescription = a.AreaDescription, + EffectiveUtc = a.EffectiveUtc, + OnsetUtc = a.OnsetUtc, + ExpiresUtc = a.ExpiresUtc, + SentUtc = a.SentUtc, + FirstSeenUtc = a.FirstSeenUtc, + LastUpdatedUtc = a.LastUpdatedUtc, + NotificationSent = false, + ReferencesExternalId = a.ReferencesExternalId, + Geocodes = a.Geocodes, + Polygon = a.Polygon, + CenterGeoLocation = a.CenterGeoLocation + }).ToList(); + } + + private static int MapCategory(string category) + { + return category?.ToLowerInvariant() switch + { + "met" => (int)WeatherAlertCategory.Met, + "fire" => (int)WeatherAlertCategory.Fire, + "health" => (int)WeatherAlertCategory.Health, + "env" => (int)WeatherAlertCategory.Env, + _ => (int)WeatherAlertCategory.Other + }; + } + + private static WeatherAlertSeverity MapSeverity(string severity) + { + return severity?.ToLowerInvariant() switch + { + "extreme" => WeatherAlertSeverity.Extreme, + "severe" => WeatherAlertSeverity.Severe, + "moderate" => WeatherAlertSeverity.Moderate, + "minor" => WeatherAlertSeverity.Minor, + _ => WeatherAlertSeverity.Unknown + }; + } + + private static WeatherAlertUrgency MapUrgency(string urgency) + { + return urgency?.ToLowerInvariant() switch + { + "immediate" => WeatherAlertUrgency.Immediate, + "expected" => WeatherAlertUrgency.Expected, + "future" => WeatherAlertUrgency.Future, + "past" => WeatherAlertUrgency.Past, + _ => WeatherAlertUrgency.Unknown + }; + } + + private static WeatherAlertCertainty MapCertainty(string certainty) + { + return certainty?.ToLowerInvariant() switch + { + "observed" => WeatherAlertCertainty.Observed, + "likely" => WeatherAlertCertainty.Likely, + "possible" => WeatherAlertCertainty.Possible, + "unlikely" => WeatherAlertCertainty.Unlikely, + _ => WeatherAlertCertainty.Unknown + }; + } + + private static int MapStatus(string msgType) + { + return msgType?.ToLowerInvariant() switch + { + "alert" => (int)WeatherAlertStatus.Active, + "update" => (int)WeatherAlertStatus.Updated, + "cancel" => (int)WeatherAlertStatus.Cancelled, + _ => (int)WeatherAlertStatus.Active + }; + } + + private static string[] ParseAreaFilter(string areaFilter) + { + if (string.IsNullOrWhiteSpace(areaFilter)) + return Array.Empty(); + + var trimmed = areaFilter.Trim(); + + if (trimmed.StartsWith("[")) + { + try + { + var parsed = System.Text.Json.JsonSerializer.Deserialize(trimmed); + if (parsed != null && parsed.Length > 0) + return parsed; + } + catch { } + } + + return trimmed.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } } } diff --git a/Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs b/Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs index a836f4c8..95a6af2d 100644 --- a/Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs +++ b/Providers/Resgrid.Providers.Weather/MeteoAlarmWeatherAlertProvider.cs @@ -1,5 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Resgrid.Model; @@ -9,6 +14,15 @@ namespace Resgrid.Providers.Weather { public class MeteoAlarmWeatherAlertProvider : IWeatherAlertProvider { + private static readonly HttpClient _httpClient = new HttpClient(); + private const string DefaultBaseUrl = "https://feeds.meteoalarm.org/api/v1/warnings/feeds-fullcap"; + + static MeteoAlarmWeatherAlertProvider() + { + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + _httpClient.Timeout = TimeSpan.FromSeconds(30); + } + public WeatherAlertSourceType SourceType => WeatherAlertSourceType.MeteoAlarm; public async Task> FetchAlertsAsync(WeatherAlertSource source, CancellationToken ct = default) @@ -16,17 +30,336 @@ public async Task> FetchAlertsAsync(WeatherAlertSource source // Check shared cache first if (WeatherAlertResponseCache.TryGet(SourceType, source.AreaFilter, out var cachedAlerts)) { - return cachedAlerts; + return CloneAlertsForSource(cachedAlerts, source); } - // TODO: Implement MeteoAlarm API integration - // Endpoint: https://feeds.meteoalarm.org/api/v1/ var alerts = new List(); + var baseUrl = !string.IsNullOrEmpty(source.CustomEndpoint) ? source.CustomEndpoint : DefaultBaseUrl; + + // AreaFilter is expected to be an ISO 3166-1 alpha-2 country code (e.g., "DE", "FR", "IT") + // or comma-separated list of country codes + var countries = ParseAreaFilter(source.AreaFilter); + + if (countries.Length == 0) + countries = new[] { "" }; // Fetch all if no filter + + foreach (var country in countries) + { + try + { + var url = baseUrl; + if (!string.IsNullOrEmpty(country)) + url += $"/{country.ToUpperInvariant()}"; + + var countryAlerts = await FetchAlertsFromApiAsync(url, source, ct); + alerts.AddRange(countryAlerts); + } + catch (Exception) + { + // Skip errors for individual countries, continue with others + continue; + } + } + + // Deduplicate by ExternalId + alerts = alerts + .GroupBy(a => a.ExternalId) + .Select(g => g.First()) + .ToList(); // Store in shared cache WeatherAlertResponseCache.Set(SourceType, source.AreaFilter, alerts); return alerts; } + + private async Task> FetchAlertsFromApiAsync(string url, WeatherAlertSource source, CancellationToken ct) + { + var alerts = new List(); + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd("Resgrid/1.0 (weather-alerts)"); + + if (!string.IsNullOrEmpty(source.ApiKey)) + request.Headers.Add("X-API-Key", source.ApiKey); + + if (!string.IsNullOrEmpty(source.LastETag)) + request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(source.LastETag)); + + HttpResponseMessage response; + try + { + response = await _httpClient.SendAsync(request, ct); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"MeteoAlarm HTTP request failed for URL '{url}': {ex.Message}", ex); + } + + if (response.StatusCode == System.Net.HttpStatusCode.NotModified) + return alerts; + + response.EnsureSuccessStatusCode(); + + if (response.Headers.ETag != null) + source.LastETag = response.Headers.ETag.Tag; + + var json = await response.Content.ReadAsStringAsync(); + + var contentType = response.Content.Headers.ContentType?.MediaType ?? ""; + if (!contentType.Contains("json", StringComparison.OrdinalIgnoreCase)) + { + var snippet = json.Length > 200 ? json.Substring(0, 200) : json; + throw new InvalidOperationException( + $"MeteoAlarm API returned non-JSON response (Content-Type: '{contentType}') for URL '{url}'. " + + $"Response body starts with: {snippet}"); + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + var snippet = json.Length > 200 ? json.Substring(0, 200) : json; + throw new InvalidOperationException( + $"Failed to parse MeteoAlarm JSON response for URL '{url}'. " + + $"Response body starts with: {snippet}", ex); + } + + using (doc) + { + var root = doc.RootElement; + + // MeteoAlarm feeds-fullcap returns GeoJSON FeatureCollection + if (!root.TryGetProperty("features", out var features)) + return alerts; + + foreach (var feature in features.EnumerateArray()) + { + try + { + var props = feature.GetProperty("properties"); + + // The CAP data may be nested under "cap" or directly in properties + var capElement = props.TryGetProperty("cap", out var capProp) ? capProp : props; + + var alert = new WeatherAlert + { + DepartmentId = source.DepartmentId, + WeatherAlertSourceId = source.WeatherAlertSourceId, + ExternalId = GetStringProp(props, "identifier") ?? GetStringProp(props, "id"), + Sender = GetStringProp(capElement, "sender") ?? GetStringProp(capElement, "senderName"), + Event = GetStringProp(capElement, "event"), + AlertCategory = MapCategory(GetStringProp(capElement, "category")), + Severity = (int)MapSeverity(GetStringProp(capElement, "severity")), + Urgency = (int)MapUrgency(GetStringProp(capElement, "urgency")), + Certainty = (int)MapCertainty(GetStringProp(capElement, "certainty")), + Status = MapStatus(GetStringProp(capElement, "msgType") ?? GetStringProp(props, "msgType")), + Headline = GetStringProp(capElement, "headline"), + Description = GetStringProp(capElement, "description"), + Instruction = GetStringProp(capElement, "instruction"), + AreaDescription = GetStringProp(capElement, "areaDesc"), + EffectiveUtc = GetDateProp(capElement, "effective") ?? DateTime.UtcNow, + OnsetUtc = GetDateProp(capElement, "onset"), + ExpiresUtc = GetDateProp(capElement, "expires"), + SentUtc = GetDateProp(capElement, "sent") ?? GetDateProp(props, "sent"), + FirstSeenUtc = DateTime.UtcNow, + LastUpdatedUtc = DateTime.UtcNow, + NotificationSent = false + }; + + // Extract references + var references = GetStringProp(capElement, "references"); + if (!string.IsNullOrEmpty(references)) + alert.ReferencesExternalId = references; + + // Extract geocodes + if (capElement.TryGetProperty("geocode", out var geocode)) + alert.Geocodes = geocode.GetRawText(); + + // Extract polygon from GeoJSON geometry + if (feature.TryGetProperty("geometry", out var geometry) && geometry.ValueKind != JsonValueKind.Null) + { + alert.Polygon = geometry.GetRawText(); + alert.CenterGeoLocation = ComputeCenterFromGeoJson(geometry); + } + + alerts.Add(alert); + } + catch (Exception) + { + // Skip malformed alerts, continue with others + continue; + } + } + } + + return alerts; + } + + private static string ComputeCenterFromGeoJson(JsonElement geometry) + { + try + { + if (!geometry.TryGetProperty("coordinates", out var coords) || coords.GetArrayLength() == 0) + return null; + + var geometryType = GetStringProp(geometry, "type") ?? ""; + + // Handle Polygon: coordinates is [[[lng,lat], ...]] + // Handle MultiPolygon: coordinates is [[[[lng,lat], ...]], ...] + JsonElement ring; + if (geometryType.Equals("MultiPolygon", StringComparison.OrdinalIgnoreCase)) + ring = coords[0][0]; // First polygon, outer ring + else + ring = coords[0]; // Outer ring + + double totalLat = 0, totalLng = 0; + int count = 0; + + foreach (var point in ring.EnumerateArray()) + { + totalLng += point[0].GetDouble(); + totalLat += point[1].GetDouble(); + count++; + } + + if (count > 0) + return $"{(totalLat / count).ToString(CultureInfo.InvariantCulture)},{(totalLng / count).ToString(CultureInfo.InvariantCulture)}"; + } + catch { } + + return null; + } + + private static List CloneAlertsForSource(List cachedAlerts, WeatherAlertSource source) + { + return cachedAlerts.Select(a => new WeatherAlert + { + DepartmentId = source.DepartmentId, + WeatherAlertSourceId = source.WeatherAlertSourceId, + ExternalId = a.ExternalId, + Sender = a.Sender, + Event = a.Event, + AlertCategory = a.AlertCategory, + Severity = a.Severity, + Urgency = a.Urgency, + Certainty = a.Certainty, + Status = a.Status, + Headline = a.Headline, + Description = a.Description, + Instruction = a.Instruction, + AreaDescription = a.AreaDescription, + EffectiveUtc = a.EffectiveUtc, + OnsetUtc = a.OnsetUtc, + ExpiresUtc = a.ExpiresUtc, + SentUtc = a.SentUtc, + FirstSeenUtc = a.FirstSeenUtc, + LastUpdatedUtc = a.LastUpdatedUtc, + NotificationSent = false, + ReferencesExternalId = a.ReferencesExternalId, + Geocodes = a.Geocodes, + Polygon = a.Polygon, + CenterGeoLocation = a.CenterGeoLocation + }).ToList(); + } + + private static string GetStringProp(JsonElement element, string name) + { + if (element.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String) + return prop.GetString(); + return null; + } + + private static DateTime? GetDateProp(JsonElement element, string name) + { + var value = GetStringProp(element, name); + if (!string.IsNullOrEmpty(value) && DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var dto)) + return dto.UtcDateTime; + return null; + } + + private static int MapCategory(string category) + { + return category?.ToLowerInvariant() switch + { + "met" => (int)WeatherAlertCategory.Met, + "fire" => (int)WeatherAlertCategory.Fire, + "health" => (int)WeatherAlertCategory.Health, + "env" => (int)WeatherAlertCategory.Env, + _ => (int)WeatherAlertCategory.Other + }; + } + + private static WeatherAlertSeverity MapSeverity(string severity) + { + return severity?.ToLowerInvariant() switch + { + "extreme" => WeatherAlertSeverity.Extreme, + "severe" => WeatherAlertSeverity.Severe, + "moderate" => WeatherAlertSeverity.Moderate, + "minor" => WeatherAlertSeverity.Minor, + _ => WeatherAlertSeverity.Unknown + }; + } + + private static WeatherAlertUrgency MapUrgency(string urgency) + { + return urgency?.ToLowerInvariant() switch + { + "immediate" => WeatherAlertUrgency.Immediate, + "expected" => WeatherAlertUrgency.Expected, + "future" => WeatherAlertUrgency.Future, + "past" => WeatherAlertUrgency.Past, + _ => WeatherAlertUrgency.Unknown + }; + } + + private static WeatherAlertCertainty MapCertainty(string certainty) + { + return certainty?.ToLowerInvariant() switch + { + "observed" => WeatherAlertCertainty.Observed, + "likely" => WeatherAlertCertainty.Likely, + "possible" => WeatherAlertCertainty.Possible, + "unlikely" => WeatherAlertCertainty.Unlikely, + _ => WeatherAlertCertainty.Unknown + }; + } + + private static int MapStatus(string msgType) + { + return msgType?.ToLowerInvariant() switch + { + "alert" => (int)WeatherAlertStatus.Active, + "update" => (int)WeatherAlertStatus.Updated, + "cancel" => (int)WeatherAlertStatus.Cancelled, + _ => (int)WeatherAlertStatus.Active + }; + } + + private static string[] ParseAreaFilter(string areaFilter) + { + if (string.IsNullOrWhiteSpace(areaFilter)) + return Array.Empty(); + + var trimmed = areaFilter.Trim(); + + if (trimmed.StartsWith("[")) + { + try + { + var parsed = JsonSerializer.Deserialize(trimmed); + if (parsed != null && parsed.Length > 0) + return parsed; + } + catch { } + } + + return trimmed.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } } } diff --git a/Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs b/Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs index f963bc67..47896800 100644 --- a/Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs +++ b/Providers/Resgrid.Providers.Weather/WeatherAlertResponseCache.cs @@ -14,10 +14,15 @@ public class WeatherAlertResponseCache public static bool TryGet(WeatherAlertSourceType sourceType, string areaFilter, out List alerts) { var key = BuildKey(sourceType, areaFilter); - if (_cache.TryGetValue(key, out var entry) && entry.ExpiresUtc > DateTime.UtcNow) + if (_cache.TryGetValue(key, out var entry)) { - alerts = entry.Alerts; - return true; + if (entry.ExpiresUtc > DateTime.UtcNow) + { + alerts = entry.Alerts; + return true; + } + + _cache.TryRemove(key, out _); } alerts = null; return false; diff --git a/Tests/Resgrid.Tests/Mocks/MockTrainingAttachmentRepository.cs b/Tests/Resgrid.Tests/Mocks/MockTrainingAttachmentRepository.cs new file mode 100644 index 00000000..db6263e8 --- /dev/null +++ b/Tests/Resgrid.Tests/Mocks/MockTrainingAttachmentRepository.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; + +namespace Resgrid.Tests.Mocks +{ + /// + /// In-memory mock for + /// + public sealed class MockTrainingAttachmentRepository : ITrainingAttachmentRepository + { + private readonly List _attachments = new List(); + private int _nextId = 1; + + public Task> GetAllAsync() + => Task.FromResult>(_attachments.ToList()); + + public Task GetByIdAsync(object id) + { + var intId = (int)id; + var attachment = _attachments.FirstOrDefault(a => a.TrainingAttachmentId == intId); + return Task.FromResult(attachment); + } + + public Task> GetAllByDepartmentIdAsync(int departmentId) + => Task.FromResult>(new List()); + + public Task> GetAllByUserIdAsync(string userId) + => Task.FromResult>(new List()); + + public Task InsertAsync(TrainingAttachment entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + entity.TrainingAttachmentId = _nextId++; + _attachments.Add(entity); + return Task.FromResult(entity); + } + + public Task UpdateAsync(TrainingAttachment entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + var existing = _attachments.FirstOrDefault(a => a.TrainingAttachmentId == entity.TrainingAttachmentId); + if (existing != null) + { + _attachments.Remove(existing); + } + _attachments.Add(entity); + return Task.FromResult(entity); + } + + public Task DeleteAsync(TrainingAttachment entity, CancellationToken cancellationToken) + { + var existing = _attachments.FirstOrDefault(a => a.TrainingAttachmentId == entity.TrainingAttachmentId); + if (existing != null) + { + _attachments.Remove(existing); + } + return Task.FromResult(true); + } + + public Task SaveOrUpdateAsync(TrainingAttachment entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + if (entity.TrainingAttachmentId == 0) + { + entity.TrainingAttachmentId = _nextId++; + _attachments.Add(entity); + } + else + { + var existing = _attachments.FirstOrDefault(a => a.TrainingAttachmentId == entity.TrainingAttachmentId); + if (existing != null) + { + _attachments.Remove(existing); + } + _attachments.Add(entity); + } + return Task.FromResult(entity); + } + + public Task DeleteMultipleAsync(TrainingAttachment entity, string parentKeyName, object parentKeyId, List ids, CancellationToken cancellationToken) + => Task.FromResult(true); + + public Task> GetTrainingAttachmentsByTrainingIdAsync(int trainingId) + { + var result = _attachments.Where(a => a.TrainingId == trainingId).ToList(); + return Task.FromResult>(result); + } + + public void SeedAttachment(TrainingAttachment attachment) + { + if (attachment.TrainingAttachmentId == 0) + { + attachment.TrainingAttachmentId = _nextId++; + } + else + { + _nextId = System.Math.Max(_nextId, attachment.TrainingAttachmentId + 1); + } + _attachments.Add(attachment); + } + } +} \ No newline at end of file diff --git a/Tests/Resgrid.Tests/Mocks/MockTrainingQuestionRepository.cs b/Tests/Resgrid.Tests/Mocks/MockTrainingQuestionRepository.cs new file mode 100644 index 00000000..d47fe531 --- /dev/null +++ b/Tests/Resgrid.Tests/Mocks/MockTrainingQuestionRepository.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; + +namespace Resgrid.Tests.Mocks +{ + /// + /// In-memory mock for + /// + public sealed class MockTrainingQuestionRepository : ITrainingQuestionRepository + { + private readonly List _questions = new List(); + private int _nextId = 1; + + public Task> GetAllAsync() + => Task.FromResult>(_questions.ToList()); + + public Task GetByIdAsync(object id) + { + var intId = (int)id; + var question = _questions.FirstOrDefault(q => q.TrainingQuestionId == intId); + return Task.FromResult(question); + } + + public Task> GetAllByDepartmentIdAsync(int departmentId) + => Task.FromResult>(new List()); + + public Task> GetAllByUserIdAsync(string userId) + => Task.FromResult>(new List()); + + public Task InsertAsync(TrainingQuestion entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + entity.TrainingQuestionId = _nextId++; + _questions.Add(entity); + return Task.FromResult(entity); + } + + public Task UpdateAsync(TrainingQuestion entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + var existing = _questions.FirstOrDefault(q => q.TrainingQuestionId == entity.TrainingQuestionId); + if (existing != null) + { + _questions.Remove(existing); + } + _questions.Add(entity); + return Task.FromResult(entity); + } + + public Task DeleteAsync(TrainingQuestion entity, CancellationToken cancellationToken) + { + var existing = _questions.FirstOrDefault(q => q.TrainingQuestionId == entity.TrainingQuestionId); + if (existing != null) + { + _questions.Remove(existing); + } + return Task.FromResult(true); + } + + public Task SaveOrUpdateAsync(TrainingQuestion entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + if (entity.TrainingQuestionId == 0) + { + entity.TrainingQuestionId = _nextId++; + _questions.Add(entity); + } + else + { + var existing = _questions.FirstOrDefault(q => q.TrainingQuestionId == entity.TrainingQuestionId); + if (existing != null) + { + _questions.Remove(existing); + } + _questions.Add(entity); + } + return Task.FromResult(entity); + } + + public Task DeleteMultipleAsync(TrainingQuestion entity, string parentKeyName, object parentKeyId, List ids, CancellationToken cancellationToken) + => Task.FromResult(true); + + public Task> GetTrainingQuestionsByTrainingIdAsync(int trainingId) + { + var result = _questions.Where(q => q.TrainingId == trainingId).ToList(); + return Task.FromResult>(result); + } + + public void SeedQuestion(TrainingQuestion question) + { + if (question.TrainingQuestionId == 0) + { + question.TrainingQuestionId = _nextId++; + } + else + { + _nextId = System.Math.Max(_nextId, question.TrainingQuestionId + 1); + } + _questions.Add(question); + } + } +} \ No newline at end of file diff --git a/Tests/Resgrid.Tests/Mocks/MockTrainingRepository.cs b/Tests/Resgrid.Tests/Mocks/MockTrainingRepository.cs new file mode 100644 index 00000000..f728b6e1 --- /dev/null +++ b/Tests/Resgrid.Tests/Mocks/MockTrainingRepository.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; + +namespace Resgrid.Tests.Mocks +{ + /// + /// In-memory mock for that stores trainings + /// without requiring a database connection. + /// + public sealed class MockTrainingRepository : ITrainingRepository + { + private readonly List _trainings = new List(); + private int _nextId = 1; + + public List Trainings => _trainings; + + public Task> GetAllAsync() + => Task.FromResult>(_trainings.ToList()); + + public Task GetByIdAsync(object id) + { + var intId = (int)id; + var training = _trainings.FirstOrDefault(t => t.TrainingId == intId); + return Task.FromResult(training); + } + + public Task> GetAllByDepartmentIdAsync(int departmentId) + { + var result = _trainings.Where(t => t.DepartmentId == departmentId).ToList(); + return Task.FromResult>(result); + } + + public Task> GetAllByUserIdAsync(string userId) + => Task.FromResult>(new List()); + + public Task InsertAsync(Training entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + entity.TrainingId = _nextId++; + _trainings.Add(entity); + return Task.FromResult(entity); + } + + public Task UpdateAsync(Training entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + var existing = _trainings.FirstOrDefault(t => t.TrainingId == entity.TrainingId); + if (existing != null) + { + _trainings.Remove(existing); + } + _trainings.Add(entity); + return Task.FromResult(entity); + } + + public Task DeleteAsync(Training entity, CancellationToken cancellationToken) + { + var existing = _trainings.FirstOrDefault(t => t.TrainingId == entity.TrainingId); + if (existing != null) + { + _trainings.Remove(existing); + } + return Task.FromResult(true); + } + + public Task SaveOrUpdateAsync(Training entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + if (entity.TrainingId == 0) + { + entity.TrainingId = _nextId++; + _trainings.Add(entity); + } + else + { + var existing = _trainings.FirstOrDefault(t => t.TrainingId == entity.TrainingId); + if (existing != null) + { + _trainings.Remove(existing); + } + _trainings.Add(entity); + } + return Task.FromResult(entity); + } + + public Task DeleteMultipleAsync(Training entity, string parentKeyName, object parentKeyId, List ids, CancellationToken cancellationToken) + => Task.FromResult(true); + + public List GetAllTrainings() + => _trainings.ToList(); + + public Task> GetTrainingsByDepartmentIdAsync(int departmentId) + { + var result = _trainings.Where(t => t.DepartmentId == departmentId).ToList(); + return Task.FromResult>(result); + } + + public Task GetTrainingByTrainingIdAsync(int trainingId) + { + var training = _trainings.FirstOrDefault(t => t.TrainingId == trainingId); + return Task.FromResult(training); + } + + /// + /// Helper method to seed test data + /// + public void SeedTraining(Training training) + { + if (training.TrainingId == 0) + { + training.TrainingId = _nextId++; + } + else + { + _nextId = System.Math.Max(_nextId, training.TrainingId + 1); + } + _trainings.Add(training); + } + } +} \ No newline at end of file diff --git a/Tests/Resgrid.Tests/Mocks/MockTrainingUserRepository.cs b/Tests/Resgrid.Tests/Mocks/MockTrainingUserRepository.cs new file mode 100644 index 00000000..00c024dd --- /dev/null +++ b/Tests/Resgrid.Tests/Mocks/MockTrainingUserRepository.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Model; +using Resgrid.Model.Repositories; + +namespace Resgrid.Tests.Mocks +{ + /// + /// In-memory mock for + /// + public sealed class MockTrainingUserRepository : ITrainingUserRepository + { + private readonly List _users = new List(); + private int _nextId = 1; + + public Task> GetAllAsync() + => Task.FromResult>(_users.ToList()); + + public Task GetByIdAsync(object id) + { + var intId = (int)id; + var user = _users.FirstOrDefault(u => u.TrainingUserId == intId); + return Task.FromResult(user); + } + + public Task> GetAllByDepartmentIdAsync(int departmentId) + => Task.FromResult>(new List()); + + public Task> GetAllByUserIdAsync(string userId) + { + var result = _users.Where(u => u.UserId == userId).ToList(); + return Task.FromResult>(result); + } + + public Task InsertAsync(TrainingUser entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + entity.TrainingUserId = _nextId++; + _users.Add(entity); + return Task.FromResult(entity); + } + + public Task UpdateAsync(TrainingUser entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + var existing = _users.FirstOrDefault(u => u.TrainingUserId == entity.TrainingUserId); + if (existing != null) + { + _users.Remove(existing); + } + _users.Add(entity); + return Task.FromResult(entity); + } + + public Task DeleteAsync(TrainingUser entity, CancellationToken cancellationToken) + { + var existing = _users.FirstOrDefault(u => u.TrainingUserId == entity.TrainingUserId); + if (existing != null) + { + _users.Remove(existing); + } + return Task.FromResult(true); + } + + public Task SaveOrUpdateAsync(TrainingUser entity, CancellationToken cancellationToken, bool firstLevelOnly = false) + { + if (entity.TrainingUserId == 0) + { + entity.TrainingUserId = _nextId++; + _users.Add(entity); + } + else + { + var existing = _users.FirstOrDefault(u => u.TrainingUserId == entity.TrainingUserId); + if (existing != null) + { + _users.Remove(existing); + } + _users.Add(entity); + } + return Task.FromResult(entity); + } + + public Task DeleteMultipleAsync(TrainingUser entity, string parentKeyName, object parentKeyId, List ids, CancellationToken cancellationToken) + => Task.FromResult(true); + + public Task GetTrainingUserByTrainingIdAndUserIdAsync(int trainingId, string userId) + { + var user = _users.FirstOrDefault(u => u.TrainingId == trainingId && u.UserId == userId); + return Task.FromResult(user); + } + + public void SeedUser(TrainingUser user) + { + if (user.TrainingUserId == 0) + { + user.TrainingUserId = _nextId++; + } + else + { + _nextId = System.Math.Max(_nextId, user.TrainingUserId + 1); + } + _users.Add(user); + } + } +} \ No newline at end of file diff --git a/Tests/Resgrid.Tests/Services/TrainingServiceTests.cs b/Tests/Resgrid.Tests/Services/TrainingServiceTests.cs new file mode 100644 index 00000000..1efd683d --- /dev/null +++ b/Tests/Resgrid.Tests/Services/TrainingServiceTests.cs @@ -0,0 +1,507 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Framework.Testing; +using Resgrid.Model; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; +using Resgrid.Tests.Mocks; + +namespace Resgrid.Tests.Services +{ + [TestFixture] + public class TrainingServiceTests + { + private MockTrainingRepository _trainingRepository; + private MockTrainingAttachmentRepository _attachmentRepository; + private MockTrainingQuestionRepository _questionRepository; + private MockTrainingUserRepository _userRepository; + private Mock _communicationServiceMock; + private Mock _departmentServiceMock; + private TrainingService _trainingService; + + [SetUp] + public void SetUp() + { + _trainingRepository = new MockTrainingRepository(); + _attachmentRepository = new MockTrainingAttachmentRepository(); + _questionRepository = new MockTrainingQuestionRepository(); + _userRepository = new MockTrainingUserRepository(); + _communicationServiceMock = new Mock(); + _departmentServiceMock = new Mock(); + + _trainingService = new TrainingService( + _trainingRepository, + _attachmentRepository, + _userRepository, + _questionRepository, + _communicationServiceMock.Object, + _departmentServiceMock.Object + ); + } + + #region GetTrainingByIdAsync Tests + + [Test] + public async Task GetTrainingByIdAsync_Should_Return_Training_With_Questions_And_Attachments() + { + // Arrange + var training = new Training + { + TrainingId = 1, + DepartmentId = 1, + Name = "Fire Safety Training", + Description = "Basic fire safety", + TrainingText = "Learn fire safety basics", + CreatedByUserId = TestData.Users.TestUser1Id, + CreatedOn = DateTime.UtcNow + }; + _trainingRepository.SeedTraining(training); + + var question = new TrainingQuestion + { + TrainingQuestionId = 1, + TrainingId = 1, + Question = "What is the correct response to a fire?" + }; + _questionRepository.SeedQuestion(question); + + var attachment = new TrainingAttachment + { + TrainingAttachmentId = 1, + TrainingId = 1, + FileName = "fire_safety.pdf" + }; + _attachmentRepository.SeedAttachment(attachment); + + // Act + var result = await _trainingService.GetTrainingByIdAsync(1); + + // Assert + result.Should().NotBeNull(); + result.TrainingId.Should().Be(1); + result.Name.Should().Be("Fire Safety Training"); + result.Questions.Should().NotBeNull(); + result.Questions.Should().HaveCount(1); + result.Attachments.Should().NotBeNull(); + result.Attachments.Should().HaveCount(1); + } + + [Test] + public async Task GetTrainingByIdAsync_Should_Return_Null_For_NonExistent_Training() + { + // Act + var result = await _trainingService.GetTrainingByIdAsync(999); + + // Assert + result.Should().BeNull(); + } + + #endregion + + #region GetAllTrainingsForDepartmentAsync Tests + + [Test] + public async Task GetAllTrainingsForDepartmentAsync_Should_Return_Trainings_For_Department() + { + // Arrange + _trainingRepository.SeedTraining(new Training + { + TrainingId = 1, + DepartmentId = 1, + Name = "Training 1", + Description = "Description 1", + TrainingText = "Text 1", + CreatedByUserId = TestData.Users.TestUser1Id, + CreatedOn = DateTime.UtcNow + }); + _trainingRepository.SeedTraining(new Training + { + TrainingId = 2, + DepartmentId = 1, + Name = "Training 2", + Description = "Description 2", + TrainingText = "Text 2", + CreatedByUserId = TestData.Users.TestUser1Id, + CreatedOn = DateTime.UtcNow + }); + _trainingRepository.SeedTraining(new Training + { + TrainingId = 3, + DepartmentId = 2, + Name = "Training 3 (Other Dept)", + Description = "Description 3", + TrainingText = "Text 3", + CreatedByUserId = TestData.Users.TestUser5Id, + CreatedOn = DateTime.UtcNow + }); + + // Act + var result = await _trainingService.GetAllTrainingsForDepartmentAsync(1); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result.All(t => t.DepartmentId == 1).Should().BeTrue(); + } + + [Test] + public async Task GetAllTrainingsForDepartmentAsync_Should_Return_Empty_List_For_NonExistent_Department() + { + // Act + var result = await _trainingService.GetAllTrainingsForDepartmentAsync(999); + + // Assert + result.Should().NotBeNull(); + result.Should().BeEmpty(); + } + + #endregion + + #region SaveAsync Tests + + [Test] + public async Task SaveAsync_Should_Create_New_Training() + { + // Arrange + var training = new Training + { + Name = "New Training", + Description = "New Description", + TrainingText = "New Text", + DepartmentId = 1, + CreatedByUserId = TestData.Users.TestUser1Id, + CreatedOn = DateTime.UtcNow + }; + + // Act + var result = await _trainingService.SaveAsync(training); + + // Assert + result.Should().NotBeNull(); + result.TrainingId.Should().BeGreaterThan(0); + result.Name.Should().Be("New Training"); + } + + [Test] + public async Task SaveAsync_Should_Update_Existing_Training() + { + // Arrange + var existing = new Training + { + TrainingId = 1, + DepartmentId = 1, + Name = "Original Name", + Description = "Original Description", + TrainingText = "Original Text", + CreatedByUserId = TestData.Users.TestUser1Id, + CreatedOn = DateTime.UtcNow + }; + _trainingRepository.SeedTraining(existing); + + // Act + existing.Name = "Updated Name"; + existing.Description = "Updated Description"; + var result = await _trainingService.SaveAsync(existing); + + // Assert + result.Should().NotBeNull(); + result.TrainingId.Should().Be(1); + result.Name.Should().Be("Updated Name"); + result.Description.Should().Be("Updated Description"); + } + + [Test] + public async Task SaveAsync_Should_Save_Training_With_Questions() + { + // Arrange + var training = new Training + { + Name = "Quiz Training", + Description = "Training with questions", + TrainingText = "Text", + DepartmentId = 1, + CreatedByUserId = TestData.Users.TestUser1Id, + CreatedOn = DateTime.UtcNow, + Questions = new List + { + new TrainingQuestion + { + Question = "Question 1?", + Answers = new List + { + new TrainingQuestionAnswer { Answer = "Answer A", Correct = true }, + new TrainingQuestionAnswer { Answer = "Answer B", Correct = false } + } + } + } + }; + + // Act + var result = await _trainingService.SaveAsync(training); + + // Assert + result.Should().NotBeNull(); + result.Questions.Should().NotBeNull(); + result.Questions.Should().HaveCount(1); + } + + [Test] + public async Task SaveAsync_Should_Sanitize_Html_In_Description_And_Text() + { + // Arrange + var training = new Training + { + Name = "Training", + Description = "

Safe content

", + TrainingText = "

Safe training text

", + DepartmentId = 1, + CreatedByUserId = TestData.Users.TestUser1Id, + CreatedOn = DateTime.UtcNow + }; + + // Act + var result = await _trainingService.SaveAsync(training); + + // Assert + result.Description.Should().NotContain(" + +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Trainings/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Trainings/Index.cshtml index 4a800b19..fb6aed78 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Trainings/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Trainings/Index.cshtml @@ -1,19 +1,20 @@ -@using Resgrid.Model +@using Resgrid.Model @using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Training.TrainingIndexModel +@inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | Trainings"; + ViewBag.Title = "Resgrid | " + localizer["TrainingsHeader"]; }
-

Trainings

+

@localizer["TrainingsHeader"]

@@ -21,7 +22,7 @@ { } @@ -37,16 +38,16 @@ - Name + @localizer["NameColumn"] - Description + @localizer["DescriptionColumn"] - Due Date + @localizer["DueDateColumn"] - Action + @localizer["ActionColumn"] @@ -69,7 +70,7 @@ } else { - @Html.Raw("No Due Date") + @localizer["NoDueDate"] } @@ -80,23 +81,23 @@ { if (!userRecord.Viewed) { - @Html.Raw("View Training") + @Html.Raw("" + localizer["ViewTrainingButton"] + "") } else if (userRecord.Complete && (t.Questions == null || t.Questions.Count <= 0)) { - @Html.Raw("View Training") + @Html.Raw("" + localizer["ViewTrainingButton"] + "") } else if (userRecord.Complete && userRecord.Score >= t.MinimumScore && (t.Questions != null && t.Questions.Count > 0)) { - @Html.Raw("Training Complete") + @Html.Raw("" + localizer["TrainingCompleteButton"] + "") } else if (userRecord.Complete && userRecord.Score < t.MinimumScore && (t.Questions != null && t.Questions.Count > 0)) { - @Html.Raw("Training Complete") + @Html.Raw("" + localizer["TrainingCompleteButton"] + "") } else { - @Html.Raw("View Training") + @Html.Raw("" + localizer["ViewTrainingButton"] + "") } } } @@ -104,12 +105,15 @@ @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) { + + @localizer["EditButton"] + - Report + @localizer["ReportButton"] - + - Delete + @localizer["DeleteButton"] } diff --git a/Web/Resgrid.Web/Areas/User/Views/Trainings/New.cshtml b/Web/Resgrid.Web/Areas/User/Views/Trainings/New.cshtml index 42f888d9..cd483c3e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Trainings/New.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Trainings/New.cshtml @@ -1,21 +1,22 @@ -@using Resgrid.Model +@using Resgrid.Model @model Resgrid.Web.Areas.User.Models.Training.NewTrainingModel +@inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | New Training"; + ViewBag.Title = "Resgrid | " + localizer["NewTrainingHeader"]; }
-

New Training

+

@localizer["NewTrainingHeader"]

@@ -44,7 +45,7 @@
@@ -54,12 +55,12 @@
- +
@Html.Raw(Model.Training.Description)
@@ -68,12 +69,12 @@
- +
@Html.Raw(Model.Training.TrainingText)
@@ -82,7 +83,7 @@
@@ -95,7 +96,7 @@
@@ -105,7 +106,7 @@
@@ -115,7 +116,7 @@
@@ -127,7 +128,7 @@
@@ -137,7 +138,7 @@
@@ -147,7 +148,7 @@
@@ -157,20 +158,20 @@
-
Questions are optional, if you just want your personnel to view the training that will be tracked without questions.
+
@localizer["QuestionsHelpText"]
- - - + + + @@ -182,8 +183,8 @@
- Cancel - + @localizer["CancelButton"] +
@@ -196,5 +197,20 @@ @section Scripts { + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Trainings/Quiz.cshtml b/Web/Resgrid.Web/Areas/User/Views/Trainings/Quiz.cshtml index 7fc82ebf..bace59be 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Trainings/Quiz.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Trainings/Quiz.cshtml @@ -1,27 +1,28 @@ - + @using Resgrid.Model @model Resgrid.Web.Areas.User.Models.Training.ViewTrainingModel +@inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | Training Quiz"; + ViewBag.Title = "Resgrid | " + localizer["QuizPageHeader"]; }
-

Training Quiz

+

@localizer["QuizPageHeader"]

@@ -43,16 +44,16 @@ @@ -61,7 +62,7 @@
-

Welcome to the quiz for training (@Model.Training.Name). Please select the correct answer for each question. Press the next button to begin the quiz.

+

@string.Format(localizer["QuizWelcomeText"].Value, Model.Training.Name)

@@ -91,7 +92,7 @@
-

Click the finish button below to submit your quiz answers. You can go back to ensure you answered every question you wish. Your quiz will be graded and the result will be shown on the training home page.

+

@localizer["QuizFinishText"]

@@ -100,10 +101,10 @@
@@ -141,8 +142,6 @@ onTabShow: function (tab, navigation, index) { var $total = navigation.find('li').length; var $current = index + 1; - //var $percent = ($current / $total) * 100; - //$('#rootwizard').find('.bar').css({ width: $percent + '%' }); // If it's the last tab then hide the last button and show the finish instead if ($current >= $total) { @@ -158,17 +157,10 @@ return false; }, onNext: function (tab, navigation, index) { - //var $validate = $("#trainingquiz").data('bootstrapValidator').validate(); - - //var $valid = $validate.isValid(); - //if (!$valid) { - // return false; - //} } }); $('#rootwizard .finish').click(function () { - //$("form")[0].submit(); $("form#quiz_form")[0].submit(); }); }); diff --git a/Web/Resgrid.Web/Areas/User/Views/Trainings/Report.cshtml b/Web/Resgrid.Web/Areas/User/Views/Trainings/Report.cshtml index 0e168ab9..847d452f 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Trainings/Report.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Trainings/Report.cshtml @@ -1,28 +1,29 @@ - + @using Resgrid.Model.Helpers @model Resgrid.Web.Areas.User.Models.Training.TrainingReportView +@inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | Training Report"; + ViewBag.Title = "Resgrid | " + localizer["TrainingReportHeader"]; Layout = "~/Areas/User/Views/Shared/_UserLayout.cshtml"; } @section Styles { - + }
-

Training Report

+

@localizer["TrainingReportHeader"]

@@ -38,22 +39,22 @@
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Trainings/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Trainings/View.cshtml index 12f823d1..0ff8a4ce 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Trainings/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Trainings/View.cshtml @@ -1,11 +1,12 @@ -@using Resgrid.Web.Helpers +@using Resgrid.Web.Helpers @model Resgrid.Web.Areas.User.Models.Training.ViewTrainingModel +@inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | View Training"; + ViewBag.Title = "Resgrid | " + localizer["ViewTrainingHeader"]; } @section Styles { - + } @{ @@ -14,16 +15,16 @@
-

View Training

+

@localizer["ViewTrainingHeader"]

@@ -44,13 +45,13 @@
-
Status:
-
Active
+
@localizer["StatusLabel"]
+
@localizer["ActiveLabel"]
-
Created By:
+
@localizer["CreatedByLabel"]
@Model.CreatorUserName
@@ -58,7 +59,7 @@
-
Due By:
+
@localizer["DueByLabel"]
@if (Model.Training.ToBeCompletedBy != null) { @@ -66,14 +67,14 @@ } else { - No Due Date + @localizer["NoDueDate"] }
-
Created On:
+
@localizer["CreatedOnLabel"]
@Model.Training.CreatedOn.ToShortDateString()
@@ -81,7 +82,7 @@
-
Description:
+
@localizer["DescriptionLabel"]:
@Html.Raw(Model.Training.Description)
@@ -101,11 +102,11 @@
-

Training Attachments

+

@localizer["TrainingAttachmentsHeader"]

- Below are the files (attachments) for this training. Please download, open and review all of these attachments as well as the training text. Information in these attachments can be questions in the training quiz. + @localizer["AttachmentsHelpText"]

-
Training files
+
@localizer["TrainingFilesHeader"]
    @if (Model.Training.Attachments != null && Model.Training.Attachments.Count > 0) { @@ -120,12 +121,12 @@ @if (Model.Training.Questions != null && Model.Training.Questions.Count > 0 && (currentUser != null && !currentUser.Complete)) {
    -

    Training Quiz

    +

    @localizer["TrainingQuizHeader"]

    - This training has a quiz. Please review all the of the training text and all attachments to the training before you take the quiz. + @localizer["QuizHelpText"]

    - - Take Quiz + + @localizer["TakeQuizButton"]
    } diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/training/resgrid.training.edittraining.js b/Web/Resgrid.Web/wwwroot/js/app/internal/training/resgrid.training.edittraining.js new file mode 100644 index 00000000..6a37da64 --- /dev/null +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/training/resgrid.training.edittraining.js @@ -0,0 +1,121 @@ +var resgrid; +(function (resgrid) { + var training; + (function (training) { + var edittraining; + (function (edittraining) { + var i18n = (typeof resgridTrainingsI18n !== 'undefined') ? resgridTrainingsI18n : {}; + + function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + $(document).ready(function () { + resgrid.common.analytics.track('Training - Edit'); + + var quillDescription = new Quill('#editor-container', { + placeholder: '', + theme: 'snow' + }); + + var quillTraining = new Quill('#editor-container2', { + placeholder: '', + theme: 'snow' + }); + + $(document).on('submit', '#editTrainingForm', function () { + $('#Training_Description').val(quillDescription.root.innerHTML); + $('#Training_TrainingText').val(quillTraining.root.innerHTML); + + return true; + }); + + // Date picker - no time needed + $('#Training_ToBeCompletedBy').datetimepicker({ + timepicker: false, + format: 'm/d/Y', + scrollMonth: false, + scrollInput: false + }); + + // File upload: use native HTML file input (no Kendo Upload needed) + + $('#SendToAll').change(function () { + if (this.checked) { + $('#groupsToAdd').prop('disabled', true).trigger('change.select2'); + $('#rolesToAdd').prop('disabled', true).trigger('change.select2'); + $('#usersToAdd').prop('disabled', true).trigger('change.select2'); + } else { + $('#groupsToAdd').prop('disabled', false).trigger('change.select2'); + $('#rolesToAdd').prop('disabled', false).trigger('change.select2'); + $('#usersToAdd').prop('disabled', false).trigger('change.select2'); + } + }).trigger('change'); + + function initSelect2(selector, placeholder, url) { + $(selector).select2({ + placeholder: placeholder, + allowClear: true, + ajax: { + url: url, + dataType: 'json', + processResults: function (data) { + return { + results: $.map(data, function (item) { + return { id: item.Id, text: item.Name }; + }) + }; + } + } + }); + } + + initSelect2('#groupsToAdd', i18n.selectGroups || 'Select groups...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=1'); + initSelect2('#rolesToAdd', i18n.selectRoles || 'Select roles...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=2'); + initSelect2('#usersToAdd', i18n.selectUsers || 'Select users...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=3&filterSelf=true'); + // Derive questionsCount from existing server-rendered question rows to avoid index collisions + var maxIndex = 0; + $('#questions tbody').first().find('input[name^="question_"], textarea[name^="question_"]').each(function () { + var match = $(this).attr('name').match(/^question_(\d+)$/); + if (match) { + var idx = parseInt(match[1], 10); + if (idx > maxIndex) maxIndex = idx; + } + }); + resgrid.training.edittraining.questionsCount = maxIndex; + }); + function addQuestion() { + var removeTooltip = escapeHtml(i18n.removeQuestionTooltip || 'Remove this question'); + resgrid.training.edittraining.questionsCount++; + $('#questions tbody').first().append("
"); + } + edittraining.addQuestion = addQuestion; + function generateAnswersTable(count) { + var addAnswerLabel = escapeHtml(i18n.addAnswer || 'Add Answer'); + var addAnswerTooltip = escapeHtml(i18n.addAnswerTooltip || 'Add Answers to Question'); + var correctLabel = escapeHtml(i18n.correct || 'Correct'); + var answerTextLabel = escapeHtml(i18n.answerText || 'Answer Text'); + var answersTable = '
QuestionAnswers Add Question@localizer["QuestionColumn"]@localizer["AnswersColumn"] @localizer["AddQuestionButton"]
- Name + @localizer["NameColumn"] - Group + @localizer["GroupColumn"] - Viewed + @localizer["ViewedColumn"] - Completed + @localizer["CompletedColumn"] - Score + @localizer["ScoreColumn"] - Result + @localizer["ResultColumn"] @@ -78,7 +79,7 @@ } else { - Not Viewed + @localizer["NotViewed"] } @@ -88,7 +89,7 @@ } else { - Not Completed + @localizer["NotCompleted"] } @@ -98,7 +99,7 @@ } else { - No Score + @localizer["NoScore"] } @@ -106,24 +107,24 @@ { if (@u.Score >= Model.Training.MinimumScore) { - Pass + @localizer["PassLabel"] } else { - Failed + @localizer["FailedLabel"] } } else if (u.Viewed && Model.Training.Questions == null || Model.Training.Questions.Count <= 0) { - Complete + @localizer["CompleteLabel"] } else { - Pending + @localizer["PendingLabel"] } - Reset User + @localizer["ResetUserButton"]
" + resgrid.training.edittraining.generateAnswersTable(edittraining.questionsCount) + "
' + correctLabel + '' + answerTextLabel + ' ' + addAnswerLabel + '
'; + return answersTable; + } + edittraining.generateAnswersTable = generateAnswersTable; + function addAnswer(count) { + var id = generate(4); + var answerRequired = escapeHtml(i18n.answerRequired || 'Answer is required'); + var removeAnswerTooltip = escapeHtml(i18n.removeAnswerTooltip || 'Remove this answer from the question'); + $('#answersTable_' + count + ' tbody').append(""); + } + edittraining.addAnswer = addAnswer; + function removeQuestion(index) { + $('#questionRow_' + index).remove(); + } + edittraining.removeQuestion = removeQuestion; + var _answerIdCounter = 0; + function generate() { + return ++_answerIdCounter; + } + edittraining.generate = generate; + })(edittraining = training.edittraining || (training.edittraining = {})); + })(training = resgrid.training || (resgrid.training = {})); +})(resgrid || (resgrid = {})); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/training/resgrid.training.newtraining.js b/Web/Resgrid.Web/wwwroot/js/app/internal/training/resgrid.training.newtraining.js index a703eb69..fda46166 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/training/resgrid.training.newtraining.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/training/resgrid.training.newtraining.js @@ -5,6 +5,13 @@ var resgrid; (function (training) { var newtraining; (function (newtraining) { + var i18n = (typeof resgridTrainingsI18n !== 'undefined') ? resgridTrainingsI18n : {}; + + function escapeHtml(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + $(document).ready(function () { resgrid.common.analytics.track('Training - New'); @@ -65,37 +72,36 @@ var resgrid; }); } - initSelect2('#groupsToAdd', 'Select groups...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=1'); - initSelect2('#rolesToAdd', 'Select roles...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=2'); - initSelect2('#usersToAdd', 'Select users...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=3&filterSelf=true'); + initSelect2('#groupsToAdd', i18n.selectGroups || 'Select groups...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=1'); + initSelect2('#rolesToAdd', i18n.selectRoles || 'Select roles...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=2'); + initSelect2('#usersToAdd', i18n.selectUsers || 'Select users...', resgrid.absoluteBaseUrl + '/User/Department/GetRecipientsForGrid?filter=3&filterSelf=true'); resgrid.training.newtraining.questionsCount = 0; }); function addQuestion() { + var removeTooltip = escapeHtml(i18n.removeQuestionTooltip || 'Remove this question'); resgrid.training.newtraining.questionsCount++; - $('#questions tbody').first().append("" + resgrid.training.newtraining.generateAnswersTable(newtraining.questionsCount) + ""); + $('#questions tbody').first().append("" + resgrid.training.newtraining.generateAnswersTable(newtraining.questionsCount) + ""); } newtraining.addQuestion = addQuestion; function generateAnswersTable(count) { - var answersTable = '
AnsAnswer Text Add Answer
'; + var addAnswerLabel = escapeHtml(i18n.addAnswer || 'Add Answer'); + var addAnswerTooltip = escapeHtml(i18n.addAnswerTooltip || 'Add Answers to Question'); + var correctLabel = escapeHtml(i18n.correct || 'Correct'); + var answerTextLabel = escapeHtml(i18n.answerText || 'Answer Text'); + var answersTable = '
' + correctLabel + '' + answerTextLabel + ' ' + addAnswerLabel + '
'; return answersTable; } newtraining.generateAnswersTable = generateAnswersTable; function addAnswer(count) { var id = generate(4); - $('#answersTable_' + count + ' tbody').append(""); - //addGroupRoleField('answerForQuestion_' + count + '_' + timestamp.getUTCMilliseconds()); + var answerRequired = escapeHtml(i18n.answerRequired || 'Answer is required'); + var removeAnswerTooltip = escapeHtml(i18n.removeAnswerTooltip || 'Remove this answer from the question'); + $('#answersTable_' + count + ' tbody').append(""); } newtraining.addAnswer = addAnswer; - function generate(length) { - var arr = []; - var n; - for (var i = 0; i < length; i++) { - do - n = Math.floor(Math.random() * 20 + 1); - while (arr.indexOf(n) !== -1); - arr[i] = n; - } - return arr.join(''); + var _answerIdCounter = 0; + function generate() { + return ++_answerIdCounter; } newtraining.generate = generate; })(newtraining = training.newtraining || (training.newtraining = {}));