diff --git a/app/controllers/AdminController.scala b/app/controllers/AdminController.scala index afc8a9cddd..ca277af839 100644 --- a/app/controllers/AdminController.scala +++ b/app/controllers/AdminController.scala @@ -752,7 +752,7 @@ class AdminController @Inject() (implicit val env: Environment[User, SessionAuth if (isAdmin(request.identity)) { val data = Json.obj( "user_stats" -> Json.toJson(UserDAOSlick.getUserStatsForAdminPage), - "organizations" -> Json.toJson(OrganizationTable.getAllOrganizations) + "organizations" -> Json.toJson(OrganizationTable.getAllTeams) ) Future.successful(Ok(data)) } else { diff --git a/app/controllers/LabelController.scala b/app/controllers/LabelController.scala index 78c9f625b6..12d2282376 100644 --- a/app/controllers/LabelController.scala +++ b/app/controllers/LabelController.scala @@ -19,6 +19,108 @@ import play.api.Logger class LabelController @Inject() (implicit val env: Environment[User, SessionAuthenticator]) extends Silhouette[User, SessionAuthenticator] with ProvidesHeader { + /** + * Fetches a single label by its ID. + * + * @param labelId The ID of the label to find. + * @return The label data as JSON if found, otherwise a 404 response. + */ + def getLabelById(labelId: Int) = UserAwareAction.async { implicit request => + Logger.info(s"Attempting to fetch label with ID: $labelId") + + // Check if the user is authenticated + request.identity match { + case Some(user) => + LabelTable.getLabelById(labelId) match { + case Some(label) => + // Map the Label object to a JSON response + val jsLabel = Json.obj( + "labelId" -> label.labelId, + "description" -> label.description, + "severity" -> label.severity, + "tags" -> label.tags + // Add other fields as necessary + ) + // Return the label data as JSON + Future.successful(Ok(jsLabel)) + case None => + // If label is not found, return a 404 Not Found response + Future.successful(NotFound(Json.obj("error" -> "Label not found"))) + } + case None => + // If user is not authenticated, redirect to login + Future.successful(Redirect("/login")) + } + } + + /** + * Delete a label by its ID. + */ + def deleteLabelById(labelId: Int) = UserAwareAction.async { implicit request => + Logger.info(s"Attempting to delete label with ID: $labelId") + + // Check if the user is authenticated + request.identity match { + case Some(user) => + // Try to delete the label + LabelTable.deleteLabelById(labelId) match { + case affectedRows if affectedRows > 0 => + // If at least one row was affected, label is deleted + Future.successful(Ok(Json.obj("message" -> s"Label with ID $labelId has been deleted."))) + case _ => + // If no rows were affected, return a 404 response + Future.successful(NotFound(Json.obj("error" -> "Label not found"))) + } + case None => + // If the user is not authenticated, redirect to login + Future.successful(Redirect("/login")) + } + } + + /** + * Updates a label's properties (severity, description, tags). + * + * @param labelId The ID of the label to update. + * @return A JSON response indicating success or failure. + */ + def updateFromUserDashboard(labelId: Int) = UserAwareAction.async(parse.json) { implicit request => + // Log the labelId for debugging purposes + Logger.info(s"Attempting to update label with ID: $labelId") + + // Extract the JSON data from the request body + val updatedData = request.body.as[JsObject] + + // Extract severity, description, and tags from the request body + val severity = (updatedData \ "severity").asOpt[Int] + val description = (updatedData \ "description").asOpt[String] + val tags = (updatedData \ "tags").asOpt[List[String]].getOrElse(List()) + + // Check if the user is authenticated + request.identity match { + case Some(user) => + // Check if the label exists + LabelTable.getLabelById(labelId) match { + case Some(labelToUpdate) => + // Proceed with updating the label + val updateResult = LabelTable.updateFromUserDashboard(labelId, severity, description, tags) + + // If update was successful, return the updated label details + if (updateResult > 0) { + Future.successful(Ok(Json.obj("message" -> s"Label with ID $labelId updated successfully."))) + } else { + // If no rows were affected, return Not Found + Future.successful(NotFound(Json.obj("error" -> "Label update failed or no changes made."))) + } + case None => + // If the label was not found, return Not Found + Future.successful(NotFound(Json.obj("error" -> "Label not found"))) + } + case None => + // If the user is not authenticated, redirect to login + Future.successful(Redirect("/login")) + } + } + /** * Fetches the labels that a user has added in the current region they are working in. * diff --git a/app/controllers/UserProfileController.scala b/app/controllers/UserProfileController.scala index 5fc788b510..03c76417af 100644 --- a/app/controllers/UserProfileController.scala +++ b/app/controllers/UserProfileController.scala @@ -21,6 +21,7 @@ import play.api.i18n.Messages import scala.concurrent.Future import play.api.mvc._ import models.user.OrganizationTable +import models.user.Organization /** * Holds the HTTP requests associated with the user dashboard. @@ -205,17 +206,43 @@ class UserProfileController @Inject() (implicit val env: Environment[User, Sessi * Creates a team and puts them in the organization table. */ def createTeam() = Action(parse.json) { request => - val orgName: String = (request.body \ "name").as[String] - val orgDescription: String = (request.body \ "description").as[String] + val orgName: String = (request.body \ "name").as[String] + val orgDescription: String = (request.body \ "description").as[String] - // Inserting into the database and capturing the generated orgId. - val orgId: Int = OrganizationTable.insert(orgName, orgDescription) + // Inserting into the database and capturing the generated orgId. + val orgId: Int = OrganizationTable.insert(orgName, orgDescription) - Ok(Json.obj( - "message" -> "Organization created successfully!", - "org_id" -> orgId - )) -} + Ok(Json.obj( + "message" -> "Organization created successfully!", + "org_id" -> orgId + )) + } + + /** + * Grabs a list of all the teams in the tables, + * regardless of open or closed status. + */ + def getTeams() = UserAwareAction.async { implicit request => + val teams: List[Organization] = OrganizationTable.getAllTeams() + + // Convert the list of organizations to JSON + val teamJson = Json.toJson(teams) + + // Return the JSON response + Future.successful(Ok(teamJson)) + } + + /** + * Grabs a list of all "open" teams in the tables. + */ + def getAllOpenTeams() = UserAwareAction.async { implicit request => + + val OpenTeams: List[Organization] = OrganizationTable.getAllOpenTeams() + + val teamJson = Json.toJson(OpenTeams) + + Future.successful(Ok(teamJson)) + } /** @@ -239,4 +266,30 @@ class UserProfileController @Inject() (implicit val env: Environment[User, Sessi Future.successful(Ok(Json.obj("error" -> "0", "message" -> "Your user id could not be found."))) } } + + /** + * Updates the open status of the specified organization. + * + * @param orgId The ID of the organization to update. + */ + def updateStatus(orgId: Int) = Action(parse.json) { request => + val isOpen = (request.body \ "isOpen").as[Boolean] + + OrganizationTable.updateStatus(orgId, isOpen) + + Ok(Json.obj("status" -> "success", "org_id" -> orgId, "isOpen" -> isOpen)) + } + + /** + * Updates the visibility status of the specified organization. + * + * @param orgId The ID of the organization to update. + */ + def updateVisibility(orgId: Int) = Action(parse.json) { request => + val isVisible = (request.body \ "isVisible").as[Boolean] + + OrganizationTable.updateVisibility(orgId, isVisible) + + Ok(Json.obj("status" -> "success", "org_id" -> orgId, "isVisible" -> isVisible)) + } } diff --git a/app/formats/json/OrganizationFormats.scala b/app/formats/json/OrganizationFormats.scala index 6e10120f25..c21c7bf3f5 100644 --- a/app/formats/json/OrganizationFormats.scala +++ b/app/formats/json/OrganizationFormats.scala @@ -9,6 +9,8 @@ object OrganizationFormats { implicit val organizationWrites: Writes[Organization] = ( (JsPath \ "orgId").write[Int] and (JsPath \ "orgName").write[String] and - (JsPath \ "orgDescription").write[String] + (JsPath \ "orgDescription").write[String] and + (JsPath \ "isOpen").write[Boolean] and + (JsPath \ "isVisible").write[Boolean] )(unlift(Organization.unapply _)) } diff --git a/app/models/label/LabelTable.scala b/app/models/label/LabelTable.scala index 86f2326612..b473c585fe 100644 --- a/app/models/label/LabelTable.scala +++ b/app/models/label/LabelTable.scala @@ -372,6 +372,51 @@ object LabelTable { labelsWithExcludedUsers.filter(_.userId === userId.toString).size.run } + /** + * Find a Label by its ID. + * + * @param labelId The ID of the label to find. + * @return An Option[Label] - Some(Label) if found, None if not found. + */ + def getLabelById(labelId: Int): Option[Label] = db.withSession { implicit session => + val query = labelsUnfiltered.filter(_.labelId === labelId).take(1) + + query.firstOption + } + + /** + * Marks a label as deleted by setting its `deleted` field to true. + * + * @param labelId The ID of the label to delete. + * @return The number of affected rows (should be 1 if the label exists and is successfully updated). + */ + def deleteLabelById(labelId: Int): Int = db.withTransaction { implicit session => + val labelToDeleteQuery = labelsUnfiltered.filter(_.labelId === labelId) + + labelToDeleteQuery.map(_.deleted).update(true) + } + + def updateFromUserDashboard(labelId: Int, severity: Option[Int], description: Option[String], tags: List[String]): Int = db.withTransaction { implicit session => + val labelToUpdateQuery = labelsUnfiltered.filter(_.labelId === labelId) + val labelToUpdate: Label = labelToUpdateQuery.first + val cleanedTags: List[String] = TagTable.cleanTagList(tags, labelToUpdate.labelTypeId) + // If the severity or tags have been changed, we need to update the label_history table as well. + if (labelToUpdate.severity != severity || labelToUpdate.tags.toSet != cleanedTags.toSet) { + // If there are multiple entries in the label_history table, then the label has been edited before and we need to + // add an entirely new entry to the table. Otherwise we can just update the existing entry. + val labelHistoryCount: Int = LabelHistoryTable.labelHistory.filter(_.labelId === labelId).length.run + if (labelHistoryCount > 1) { + LabelHistoryTable.save(LabelHistory(0, labelId, severity, cleanedTags, labelToUpdate.userId, new Timestamp(Instant.now.toEpochMilli), "User Dashboard Edit", None)) + } else { + LabelHistoryTable.labelHistory.filter(_.labelId === labelId).map(l => (l.severity, l.tags)).update((severity, cleanedTags)) + } + } + // Update the label table here. + labelToUpdateQuery + .map(l => (l.severity, l.description, l.tags)) + .update((severity, description, tags.distinct)) + } + /** * Update the metadata that users might change on the Explore page after initially placing the label. * diff --git a/app/models/user/OrganizationTable.scala b/app/models/user/OrganizationTable.scala index 551fe91335..43436ce7f2 100644 --- a/app/models/user/OrganizationTable.scala +++ b/app/models/user/OrganizationTable.scala @@ -2,15 +2,23 @@ package models.user import models.utils.MyPostgresDriver.simple._ import play.api.Play.current +import play.api.libs.json.{Json, OFormat} -case class Organization(orgId: Int, orgName: String, orgDescription: String) + +case class Organization(orgId: Int, orgName: String, orgDescription: String, isOpen: Boolean, isVisible: Boolean) + +object Organization { + implicit val format: OFormat[Organization] = Json.format[Organization] +} class OrganizationTable(tag: slick.lifted.Tag) extends Table[Organization](tag, "organization") { def orgId = column[Int]("org_id", O.PrimaryKey, O.AutoInc) def orgName = column[String]("org_name", O.NotNull) def orgDescription = column[String]("org_description", O.NotNull) + def isOpen = column[Boolean]("is_open", O.NotNull, O.Default(true)) + def isVisible = column[Boolean]("is_visible", O.NotNull, O.Default(true)) - def * = (orgId, orgName, orgDescription) <> ((Organization.apply _).tupled, Organization.unapply) + def * = (orgId, orgName, orgDescription, isOpen, isVisible) <> ((Organization.apply _).tupled, Organization.unapply) } /** @@ -20,15 +28,6 @@ object OrganizationTable { val db = play.api.db.slick.DB val organizations = TableQuery[OrganizationTable] - /** - * Gets a list of all organizations. - * - * @return A list of all organizations. - */ - def getAllOrganizations: List[Organization] = db.withSession { implicit session => - organizations.list - } - /** * Checks if the organization with the given id exists. * @@ -67,7 +66,61 @@ object OrganizationTable { * @return The auto-generated ID of the newly created organization. */ def insert(orgName: String, orgDescription: String): Int = db.withSession { implicit session => - val newOrganization = Organization(0, orgName, orgDescription) // orgId is auto-generated. + val newOrganization = Organization(0, orgName, orgDescription, true, true) // orgId is auto-generated. (organizations returning organizations.map(_.orgId)) += newOrganization } + + /** + * Gets a list of all teams, regardless of status. + * + * @return A list of all teams. + */ + def getAllTeams(): List[Organization] = db.withSession { implicit session => + organizations.list + } + + /** + * Gets a list of all "open" teams. + * + * @return A list of all open teams. + */ + def getAllOpenTeams(): List[Organization] = db.withSession { implicit session => + organizations.filter(_.isOpen === true).list + } + + /** + * Updates the visibility of an organization. + * + * @param orgId: The ID of the organization to update. + * @param isVisible: The new visibility status. + */ + def updateVisibility(orgId: Int, isVisible: Boolean): Int = db.withSession { implicit session => + val query = for { + org <- organizations if org.orgId === orgId + } yield (org.isVisible) + query.update((isVisible)) + } + + /** + * Updates the status of an organization. + * + * @param orgId: The ID of the organization to update. + * @param isOpen: The new status of the organization. + */ + def updateStatus(orgId: Int, isOpen: Boolean): Int = db.withSession { implicit session => + val query = for { + org <- organizations if org.orgId === orgId + } yield (org.isOpen) + query.update((isOpen)) + } + + /** + * Gets the organization by the given organization id. + * + * @param orgId The id of the organization. + * @return An Option containing the organization, or None if not found. + */ + def getOrganization(orgId: Int): Option[Organization] = db.withTransaction { implicit session => + organizations.filter(_.orgId === orgId).firstOption + } } diff --git a/app/models/user/UserStatTable.scala b/app/models/user/UserStatTable.scala index 846d3aab59..0726431b6c 100644 --- a/app/models/user/UserStatTable.scala +++ b/app/models/user/UserStatTable.scala @@ -306,11 +306,15 @@ object UserStatTable { "" } val orgFilter: String = orgId match { - case Some(id) => "AND user_org.org_id = " + id - case None => - // Temporarily filtering out previous course sections from the leaderboard. Need to remove soon. - if (byOrg) "AND organization.org_name NOT LIKE 'DHD206 % 2021' AND organization.org_name NOT LIKE 'DHD206 % 2022'" - else "" + case Some(id) => + // If orgId is provided, filter by orgId and also ensure the organization is visible + s"AND user_org.org_id = $id AND organization.is_visible = TRUE" + case None => + if (byOrg) { + "AND organization.is_visible = TRUE" + } else { + "" + } } // There are quite a few changes to make to the query when grouping by team/org instead of user. All of those below. val groupingCol: String = if (byOrg) "user_org.org_id" else "sidewalk_user.user_id" diff --git a/app/views/admin/index.scala.html b/app/views/admin/index.scala.html index d12ad26609..16ae47b947 100644 --- a/app/views/admin/index.scala.html +++ b/app/views/admin/index.scala.html @@ -4,6 +4,7 @@ @import models.label._ @import models.audit.AuditTaskCommentTable @import models.utils.DataFormatter +@import models.user.OrganizationTable @(title: String, user: Option[User] = None)(implicit lang: Lang) @main(title) { @@ -24,8 +25,9 @@
  • Contributions
  • Users
  • Label Search
  • +
  • Teams
  • - +
    @@ -807,7 +809,56 @@

    Users

    Login Count - + + @UserDAOSlick.getUserStatsForAdminPage.map { u => + + @u.username + @u.userId + @u.email + + @if(u.role != "Owner"){ + + } else { + @u.role + } + + + + + @u.highQuality + @u.labels + @u.ownValidated + @("%.0f".format(u.ownValidatedAgreedPct * 100))% + @u.othersValidated + @("%.0f".format(u.othersValidatedAgreedPct * 100))% + @u.signUpTime + @u.lastSignInTime + @u.signInCount + + }
    @@ -823,11 +874,146 @@

    Label Search

    +
    +

    Teams

    + + + + + + + + + + + +
    Team NameDescriptionStatusVisibility
    - + diff --git a/app/views/leaderboard.scala.html b/app/views/leaderboard.scala.html index 7c9627600f..5176154bdf 100644 --- a/app/views/leaderboard.scala.html +++ b/app/views/leaderboard.scala.html @@ -243,8 +243,8 @@

    @Messages("leaderboard.weekly.title } @if(user && user.get.role.getOrElse("") != "Anonymous" && !userOrg.isEmpty) {
    -

    @Messages("leaderboard.org.title", orgName)

    -
    @Messages("leaderboard.org.detail", orgName)
    +

    @Messages("leaderboard.org.title", OrganizationTable.getOrganizationName(UserOrgTable.getOrg(user.get.userId).get).getOrElse("Team name not found"))

    +
    @Messages("leaderboard.org.detail", OrganizationTable.getOrganizationName(UserOrgTable.getOrg(user.get.userId).get).getOrElse("Team name not found"))
    diff --git a/app/views/userProfile.scala.html b/app/views/userProfile.scala.html index 1688853124..7f9329008d 100644 --- a/app/views/userProfile.scala.html +++ b/app/views/userProfile.scala.html @@ -7,6 +7,7 @@ @(title: String, user: Option[User] = None, auditedDistance: Float)(implicit lang: Lang) @userOrg = @{UserOrgTable.getOrg(user.get.userId)} @orgName = @{userOrg.flatMap(orgId => OrganizationTable.getOrganizationName(orgId)).getOrElse("Team Name Not Found")} +@orgFound = @{userOrg.flatMap(orgId => OrganizationTable.getOrganization(orgId))} @main(title) { @navbar(user, user.map(u=> "/dashboard")) @@ -19,27 +20,54 @@ ` + '' + '' + - '' + + '' + '' + + '' + `` + '' + '' + @@ -252,8 +253,228 @@ function AdminGSVLabelView(admin, source) { self.modalLabelId = self.modal.find("#label-id"); self.modalStreetId = self.modal.find('#street-id'); self.modalRegionId = self.modal.find('#region-id'); + + self.deleteLabelButton = self.modal.find("#delete-label-button"); + self.deleteLabelButton.click(function() { + showDeleteLabelModal(); + }); + + self.editLabelButton = self.modal.find("#edit-label-button"); + self.editLabelButton.click(function() { + console.log("Edit button clicked for label", self.panorama.label.labelId); + + $.ajax({ + url: `/label/${self.panorama.label.labelId}`, + METHOD: 'GET', + success: function(label) { + showEditLabelModal(label); + }, + error: function(error) { + console.error("Error fetching label data:", error); + alert("Failed to load label data"); + } + }) + }); } + function showDeleteLabelModal() { + var confirmationModal = ` + + `; + + $('body').append(confirmationModal); + + $('#deleteConfirmationModal').modal('show'); + + $('#cancelButton').click(function() { + $('#deleteConfirmationModal').modal('hide'); + }); + + $('#yesDeleteButton').click(function() { + $.ajax({ + url: `/label/${self.panorama.label.labelId}`, + method: 'DELETE', + success: function() { + $('#deleteConfirmationModal').modal('hide'); + console.log("Label " + self.panorama.label.labelId + " deleted successfully."); + window.location.reload(); + }, + error: function(error) { + console.error("Error deleting label: " + self.panorama.label.labelId, error); + } + }) + }); + + $('#deleteConfirmationModal').on('hidden.bs.modal', function () { + $(this).remove(); + }); + } + function showEditLabelModal(label) { + const availableTags = [ + "ends abruptly", + "gravel/dirt road", + "shared pedestrian/car space", + "pedestrian lane marking", + "street has a sidewalk", + "street has no sidewalks" + ]; + + var modalText = ` + + `; + + $('body').append(modalText); + + var modal = $('#editLabelModal'); + + modal.find('#add-tag').click(function() { + var selectedTag = modal.find('#tag-select').val(); + if (selectedTag && !label.tags.includes(selectedTag)) { + label.tags.push(selectedTag); + updateTagList(); + modal.find('#tag-select').val(''); + updateTagDropdown(); + } + }); + + modal.on('click', '.remove-tag', function() { + var tagToRemove = $(this).data('tag'); + label.tags = label.tags.filter(tag => tag !== tagToRemove); + updateTagList(); + updateTagDropdown(); + }); + + function updateTagList() { + var tagListHtml = label.tags.map(tag => ` +
  • + ${tag} + +
  • + `).join(''); + modal.find('#label-tag-list').html(tagListHtml); + } + + function updateTagDropdown() { + var availableOptions = availableTags.filter(tag => !label.tags.includes(tag)); + var dropdownHtml = availableOptions.map(tag => ` + + `).join(''); + modal.find('#tag-select').html(`${dropdownHtml}`); + } + + modal.find('#save-changes-button').click(function() { + var updatedSeverity = parseInt(modal.find('#label-severity').val()) || null; + var updatedDescription = modal.find('#label-description').val(); + var updatedTags = label.tags; + + $.ajax({ + url: `/label/${self.panorama.label.labelId}`, + method: 'PUT', + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({ + severity: updatedSeverity, + description: updatedDescription, + tags: updatedTags + }), + success: function() { + console.log("Label " + self.panorama.label.labelId + " updated successfully."); + window.location.reload(); + }, + error: function(error) { + console.log("Error updating label: " + self.panorama.label.labelId, error); + } + }); + }); + + modal.find('.btn-secondary').click(function() { + modal.modal('hide'); + }); + + modal.modal('show'); + + modal.on('hidden.bs.modal', function() { + modal.remove(); + }); + } + + + /** * Get together the data on the validation and submit as a POST request. * @param action
    ${i18next.t('labelmap:label-type')}
    ${i18next.t('common:severity')}