From 9ef631e5bae57044e3e01c561a52a8fe3f349c34 Mon Sep 17 00:00:00 2001 From: binh-dam-ibigroup <56846598+binh-dam-ibigroup@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:07:30 -0500 Subject: [PATCH 1/6] refactor(MonitoredTrip): Change primary field to MobilityProfileLite. This is to accommodate the dependent traveler profile. BREAKING CHANGE: Fields from RelatedUser in MonitoredTrip.primary are no longer supported. --- .../org/opentripplanner/middleware/models/MonitoredTrip.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java index 24c7e2014..1626ca8fd 100644 --- a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java @@ -176,7 +176,7 @@ public class MonitoredTrip extends Model { */ public boolean notifyAtLeadingInterval = true; - public RelatedUser primary; + public MobilityProfileLite primary; public RelatedUser companion; public List observers = new ArrayList<>(); From 8f1f77f432ce0d12ccffd3a244c957c6b43e6b3f Mon Sep 17 00:00:00 2001 From: binh-dam-ibigroup <56846598+binh-dam-ibigroup@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:17:59 -0500 Subject: [PATCH 2/6] feat(MonitoredTrip): Add endpoint to retrieve shared trips --- .../api/MonitoredTripController.java | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java index 0ae2f0d00..cbe746d98 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java @@ -5,11 +5,15 @@ import com.mongodb.client.model.Filters; import org.bson.conversions.Bson; import org.eclipse.jetty.http.HttpStatus; +import org.opentripplanner.middleware.auth.Auth0Connection; +import org.opentripplanner.middleware.auth.RequestingUser; +import org.opentripplanner.middleware.controllers.response.ResponseList; import org.opentripplanner.middleware.models.ItineraryExistence; import org.opentripplanner.middleware.models.MonitoredTrip; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip; import org.opentripplanner.middleware.tripmonitor.jobs.MonitoredTripLocks; +import org.opentripplanner.middleware.utils.HttpUtils; import org.opentripplanner.middleware.utils.InvalidItineraryReason; import org.opentripplanner.middleware.utils.JsonUtils; import org.opentripplanner.middleware.utils.SwaggerUtils; @@ -43,6 +47,17 @@ public MonitoredTripController(String apiPrefix) { protected void buildEndpoint(ApiEndpoint baseEndpoint) { // Add the api key route BEFORE the regular CRUD methods ApiEndpoint modifiedEndpoint = baseEndpoint + // Get all trips, including shared trips created by others. + .get(path(ROOT_ROUTE + "/gettrips") + .withDescription( + "Gets a paginated list of trips created by the current user and shared trips created by others where the current user is the primary traveler or a companion or an observer." + ) + .withQueryParam(LIMIT) + .withQueryParam(OFFSET) + .withProduces(HttpUtils.JSON_ONLY) + .withResponseType(ResponseList.class), + this::getTrips, JsonUtils::toJson + ) .post(path("/checkitinerary") .withDescription("Returns the itinerary existence check results for a monitored trip.") .withRequestType(MonitoredTrip.class) @@ -53,6 +68,20 @@ protected void buildEndpoint(ApiEndpoint baseEndpoint) { super.buildEndpoint(modifiedEndpoint); } + private ResponseList getTrips(Request req, Response res) { + int limit = HttpUtils.getQueryParamFromRequest(req, LIMIT_PARAM, 0, DEFAULT_LIMIT, 100); + int offset = HttpUtils.getQueryParamFromRequest(req, OFFSET_PARAM, 0, DEFAULT_OFFSET); + RequestingUser requestingUser = Auth0Connection.getUserFromRequest(req); + + String userId = requestingUser.otpUser.id; + Bson finalFilter = Filters.or( + Filters.eq(USER_ID_PARAM, userId), + Filters.eq("primary.userId", userId), + Filters.eq("observers.email", requestingUser.otpUser.email) + ); + return persistence.getResponseList(finalFilter, offset, limit); + } + /** * Before creating a {@link MonitoredTrip}, check that the itinerary associated with the trip exists on the selected * days of the week. Update the itinerary if everything looks OK, otherwise halt the request. From c9e42180113350d6384c3c446d8fe78aa714449f Mon Sep 17 00:00:00 2001 From: binh-dam-ibigroup <56846598+binh-dam-ibigroup@users.noreply.github.com> Date: Wed, 20 Nov 2024 14:43:32 -0500 Subject: [PATCH 3/6] docs(swagger): Update snapshot --- .../latest-spark-swagger-output.yaml | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index bfae6f238..d8b5480a6 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -1031,6 +1031,35 @@ paths: description: "An error occurred while performing the request. Contact an\ \ API administrator for more information." examples: {} + /api/secure/monitoredtrip/gettrips: + get: + tags: + - "api/secure/monitoredtrip" + description: "Gets a paginated list of trips created by the current user and\ + \ shared trips created by others where the current user is the primary traveler\ + \ or a companion or an observer." + produces: + - "application/json" + parameters: + - name: "limit" + in: "query" + description: "If specified, the maximum number of items to return." + required: false + type: "string" + default: "10" + - name: "offset" + in: "query" + description: "If specified, the number of records to skip/offset." + required: false + type: "string" + default: "0" + responses: + "200": + description: "successful operation" + responseSchema: + $ref: "#/definitions/ResponseList" + schema: + $ref: "#/definitions/ResponseList" /api/secure/monitoredtrip/checkitinerary: post: tags: @@ -1686,10 +1715,10 @@ paths: "200": description: "Successful operation" examples: {} - schema: - $ref: "#/definitions/MobilityProfileLite" responseSchema: $ref: "#/definitions/MobilityProfileLite" + schema: + $ref: "#/definitions/MobilityProfileLite" "400": description: "The request was not formed properly (e.g., some required parameters\ \ may be missing). See the details of the returned response to determine\ @@ -2536,6 +2565,17 @@ definitions: $ref: "#/definitions/Fare" details: $ref: "#/definitions/FareDetails" + MobilityProfileLite: + type: "object" + properties: + userId: + type: "string" + mobilityMode: + type: "string" + email: + type: "string" + name: + type: "string" LocalizedAlert: type: "object" properties: @@ -2636,7 +2676,7 @@ definitions: notifyAtLeadingInterval: type: "boolean" primary: - $ref: "#/definitions/RelatedUser" + $ref: "#/definitions/MobilityProfileLite" companion: $ref: "#/definitions/RelatedUser" observers: From 61b4bc7c80c023e97c29b839302b5590c176a291 Mon Sep 17 00:00:00 2001 From: binh-dam-ibigroup <56846598+binh-dam-ibigroup@users.noreply.github.com> Date: Wed, 20 Nov 2024 18:54:02 -0500 Subject: [PATCH 4/6] fix(MonitoredTripController): Include trips where you are a companion. --- .../middleware/controllers/api/MonitoredTripController.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java index cbe746d98..b6aa38f40 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java @@ -77,6 +77,7 @@ private ResponseList getTrips(Request req, Response res) { Bson finalFilter = Filters.or( Filters.eq(USER_ID_PARAM, userId), Filters.eq("primary.userId", userId), + Filters.eq("companion.email", requestingUser.otpUser.email), Filters.eq("observers.email", requestingUser.otpUser.email) ); return persistence.getResponseList(finalFilter, offset, limit); From f8cc20afab3b26c2e1baaeab29af0c2dffc1afc9 Mon Sep 17 00:00:00 2001 From: binh-dam-ibigroup <56846598+binh-dam-ibigroup@users.noreply.github.com> Date: Thu, 21 Nov 2024 08:57:03 -0500 Subject: [PATCH 5/6] refactor(MonitoredTripController): Override entity filter to query shared trips, add test. --- .../controllers/api/ApiController.java | 12 ++- .../api/MonitoredTripController.java | 11 +++ .../api/MonitoredTripControllerTest.java | 83 ++++++++++++++++++- 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java index 16b2b20de..36e05f2e0 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/ApiController.java @@ -188,6 +188,14 @@ protected void buildEndpoint(ApiEndpoint baseEndpoint) { ); } + /** + * Provides an entity filter for requests on entities that contain a userId field, + * whether the request is made with the userId param or a token. + */ + protected Bson getEntityFilter(OtpUser user) { + return Filters.eq(USER_ID_PARAM, user.id); + } + /** * HTTP endpoint to get multiple entities based on the user permissions */ @@ -203,7 +211,7 @@ private ResponseList getMany(Request req, Response res) { if (userId != null) { OtpUser otpUser = Persistence.otpUsers.getById(userId); if (requestingUser.canManageEntity(otpUser)) { - return persistence.getResponseList(Filters.eq(USER_ID_PARAM, userId), offset, limit); + return persistence.getResponseList(getEntityFilter(otpUser), offset, limit); } else { res.status(HttpStatus.FORBIDDEN_403); return null; @@ -231,7 +239,7 @@ private ResponseList getMany(Request req, Response res) { } else { // For all other cases the assumption is that the request is being made by an Otp user and the requested // entities have a 'userId' parameter. Only entities that match the requesting user id are returned. - return persistence.getResponseList(Filters.eq(USER_ID_PARAM, requestingUser.otpUser.id), offset, limit); + return persistence.getResponseList(getEntityFilter(requestingUser.otpUser), offset, limit); } } diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java index b6aa38f40..113217b5a 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java @@ -10,6 +10,7 @@ import org.opentripplanner.middleware.controllers.response.ResponseList; import org.opentripplanner.middleware.models.ItineraryExistence; import org.opentripplanner.middleware.models.MonitoredTrip; +import org.opentripplanner.middleware.models.OtpUser; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip; import org.opentripplanner.middleware.tripmonitor.jobs.MonitoredTripLocks; @@ -68,6 +69,16 @@ protected void buildEndpoint(ApiEndpoint baseEndpoint) { super.buildEndpoint(modifiedEndpoint); } + @Override + protected Bson getEntityFilter(OtpUser user) { + return Filters.or( + Filters.eq(USER_ID_PARAM, user.id), + Filters.eq("primary.userId", user.id), + Filters.eq("companion.email", user.email), + Filters.eq("observers.email", user.email) + ); + } + private ResponseList getTrips(Request req, Response res) { int limit = HttpUtils.getQueryParamFromRequest(req, LIMIT_PARAM, 0, DEFAULT_LIMIT, 100); int offset = HttpUtils.getQueryParamFromRequest(req, OFFSET_PARAM, 0, DEFAULT_OFFSET); diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/MonitoredTripControllerTest.java b/src/test/java/org/opentripplanner/middleware/controllers/api/MonitoredTripControllerTest.java index 578270c74..f35fd4bcf 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/MonitoredTripControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/MonitoredTripControllerTest.java @@ -11,25 +11,29 @@ import org.opentripplanner.middleware.controllers.response.ResponseList; import org.opentripplanner.middleware.models.AdminUser; import org.opentripplanner.middleware.models.ItineraryExistence; +import org.opentripplanner.middleware.models.MobilityProfileLite; import org.opentripplanner.middleware.models.MonitoredTrip; import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.RelatedUser; import org.opentripplanner.middleware.otp.response.Itinerary; import org.opentripplanner.middleware.otp.response.Place; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.testutils.ApiTestUtils; import org.opentripplanner.middleware.testutils.OtpMiddlewareTestEnvironment; -import org.opentripplanner.middleware.testutils.OtpTestUtils; import org.opentripplanner.middleware.testutils.PersistenceTestUtils; import org.opentripplanner.middleware.utils.HttpResponseValues; import org.opentripplanner.middleware.utils.JsonUtils; import java.util.Date; -import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; import static org.opentripplanner.middleware.auth.Auth0Connection.restoreDefaultAuthDisabled; import static org.opentripplanner.middleware.auth.Auth0Connection.setAuthDisabled; @@ -66,7 +70,6 @@ public class MonitoredTripControllerTest extends OtpMiddlewareTestEnvironment { private static final String UI_QUERY_PARAMS = "?fromPlace=fromplace%3A%3A28.556631%2C-81.411781&toPlace=toplace%3A%3A28.545925%2C-81.348609&date=2020-11-13&time=14%3A21&arriveBy=false&mode=WALK%2CBUS%2CRAIL&numItineraries=3"; private static final String DUMMY_STRING = "ABCDxyz"; - private static HashMap guardianHeaders; /** * Create Otp and Admin user accounts. Create Auth0 account for just the Otp users. If @@ -146,7 +149,6 @@ void canGetOwnMonitoredTrips() throws Exception { // Multi Otp user has 'enhanced' admin credentials, still expect only 1 trip to be returned as the scope will // limit the requesting user to a single 'otp-user' user type. - // TODO: Determine if a separate admin endpoint should be maintained for getting all/combined trips. assertEquals(1, multiTrips.data.size()); // Get trips for only the multi Otp user by specifying Otp user id. @@ -225,4 +227,77 @@ private static void createMonitoredTripForUser(OtpUser otpUser) { Persistence.monitoredTrips.create(monitoredTrip); } + + @Test + void canGetSharedTrips() throws Exception { + MonitoredTrip ownTrip = new MonitoredTrip(); + ownTrip.id = "shared-trips-own-trip"; + ownTrip.userId = soloOtpUser.id; + + RelatedUser companion = new RelatedUser(); + companion.email = "companion@example.com"; + + RelatedUser soloAsCompanion = new RelatedUser(); + soloAsCompanion.email = soloOtpUser.email; + + MonitoredTrip ownTripWithCompanion = new MonitoredTrip(); + ownTripWithCompanion.id = "shared-trips-own-trip-with-companion"; + ownTripWithCompanion.companion = companion; + ownTripWithCompanion.userId = soloOtpUser.id; + + MonitoredTrip ownTripWithObservers = new MonitoredTrip(); + ownTripWithObservers.id = "shared-trips-own-trip-with-observers"; + ownTripWithObservers.observers = List.of(companion); + ownTripWithObservers.userId = soloOtpUser.id; + + MobilityProfileLite soloAsPrimary = new MobilityProfileLite(); + soloAsPrimary.userId = soloOtpUser.id; + + MobilityProfileLite otherAsPrimary = new MobilityProfileLite(); + otherAsPrimary.userId = multiOtpUser.id; + + MonitoredTrip ownTripForDependent = new MonitoredTrip(); + ownTripForDependent.id = "shared-trips-own-trip-for-dependent"; + ownTripForDependent.primary = otherAsPrimary; + ownTripForDependent.userId = soloOtpUser.id; + + MonitoredTrip otherTrip = new MonitoredTrip(); + otherTrip.id = "shared-trips-other-trip"; + otherTrip.userId = multiOtpUser.id; + + MonitoredTrip otherTripWithSoloAsDependent = new MonitoredTrip(); + otherTripWithSoloAsDependent.id = "shared-trips-other-trip-solo-primary"; + otherTripWithSoloAsDependent.primary = soloAsPrimary; + otherTripWithSoloAsDependent.userId = multiOtpUser.id; + + MonitoredTrip otherTripWithSoloAsCompanion = new MonitoredTrip(); + otherTripWithSoloAsCompanion.id = "shared-trips-other-trip-solo-companion"; + otherTripWithSoloAsCompanion.companion = soloAsCompanion; + otherTripWithSoloAsCompanion.userId = multiOtpUser.id; + + MonitoredTrip otherTripWithSoloAsObserver = new MonitoredTrip(); + otherTripWithSoloAsObserver.id = "shared-trips-other-trip-solo-observer"; + otherTripWithSoloAsObserver.observers = List.of(soloAsCompanion); + otherTripWithSoloAsObserver.userId = multiOtpUser.id; + + List trips = List.of( + ownTrip, + ownTripForDependent, + ownTripWithCompanion, + ownTripWithObservers, + otherTrip, + otherTripWithSoloAsDependent, + otherTripWithSoloAsCompanion, + otherTripWithSoloAsObserver + ); + trips.forEach(Persistence.monitoredTrips::create); + + List fetchedTrips = getMonitoredTripsForUser(MONITORED_TRIP_PATH, soloOtpUser).data; + assertEquals(trips.size() - 1, fetchedTrips.size()); + + Set ids = trips.stream().map(t -> t.id).collect(Collectors.toSet()); + ids.remove(otherTrip.id); + Set fetchedIds = fetchedTrips.stream().map(t -> t.id).collect(Collectors.toSet()); + assertTrue(ids.containsAll(fetchedIds)); + } } From a7b20821b077c9f6e0c8873774993b33e247aeb1 Mon Sep 17 00:00:00 2001 From: binh-dam-ibigroup <56846598+binh-dam-ibigroup@users.noreply.github.com> Date: Thu, 21 Nov 2024 09:06:56 -0500 Subject: [PATCH 6/6] refactor(MonitoredTripController): Remove unused endpoint, update swagger. --- .../api/MonitoredTripController.java | 30 ------------------- .../latest-spark-swagger-output.yaml | 29 ------------------ 2 files changed, 59 deletions(-) diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java index 113217b5a..49b854425 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/MonitoredTripController.java @@ -5,16 +5,12 @@ import com.mongodb.client.model.Filters; import org.bson.conversions.Bson; import org.eclipse.jetty.http.HttpStatus; -import org.opentripplanner.middleware.auth.Auth0Connection; -import org.opentripplanner.middleware.auth.RequestingUser; -import org.opentripplanner.middleware.controllers.response.ResponseList; import org.opentripplanner.middleware.models.ItineraryExistence; import org.opentripplanner.middleware.models.MonitoredTrip; import org.opentripplanner.middleware.models.OtpUser; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip; import org.opentripplanner.middleware.tripmonitor.jobs.MonitoredTripLocks; -import org.opentripplanner.middleware.utils.HttpUtils; import org.opentripplanner.middleware.utils.InvalidItineraryReason; import org.opentripplanner.middleware.utils.JsonUtils; import org.opentripplanner.middleware.utils.SwaggerUtils; @@ -48,17 +44,6 @@ public MonitoredTripController(String apiPrefix) { protected void buildEndpoint(ApiEndpoint baseEndpoint) { // Add the api key route BEFORE the regular CRUD methods ApiEndpoint modifiedEndpoint = baseEndpoint - // Get all trips, including shared trips created by others. - .get(path(ROOT_ROUTE + "/gettrips") - .withDescription( - "Gets a paginated list of trips created by the current user and shared trips created by others where the current user is the primary traveler or a companion or an observer." - ) - .withQueryParam(LIMIT) - .withQueryParam(OFFSET) - .withProduces(HttpUtils.JSON_ONLY) - .withResponseType(ResponseList.class), - this::getTrips, JsonUtils::toJson - ) .post(path("/checkitinerary") .withDescription("Returns the itinerary existence check results for a monitored trip.") .withRequestType(MonitoredTrip.class) @@ -79,21 +64,6 @@ protected Bson getEntityFilter(OtpUser user) { ); } - private ResponseList getTrips(Request req, Response res) { - int limit = HttpUtils.getQueryParamFromRequest(req, LIMIT_PARAM, 0, DEFAULT_LIMIT, 100); - int offset = HttpUtils.getQueryParamFromRequest(req, OFFSET_PARAM, 0, DEFAULT_OFFSET); - RequestingUser requestingUser = Auth0Connection.getUserFromRequest(req); - - String userId = requestingUser.otpUser.id; - Bson finalFilter = Filters.or( - Filters.eq(USER_ID_PARAM, userId), - Filters.eq("primary.userId", userId), - Filters.eq("companion.email", requestingUser.otpUser.email), - Filters.eq("observers.email", requestingUser.otpUser.email) - ); - return persistence.getResponseList(finalFilter, offset, limit); - } - /** * Before creating a {@link MonitoredTrip}, check that the itinerary associated with the trip exists on the selected * days of the week. Update the itinerary if everything looks OK, otherwise halt the request. diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index d8b5480a6..60ecf59fd 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -1031,35 +1031,6 @@ paths: description: "An error occurred while performing the request. Contact an\ \ API administrator for more information." examples: {} - /api/secure/monitoredtrip/gettrips: - get: - tags: - - "api/secure/monitoredtrip" - description: "Gets a paginated list of trips created by the current user and\ - \ shared trips created by others where the current user is the primary traveler\ - \ or a companion or an observer." - produces: - - "application/json" - parameters: - - name: "limit" - in: "query" - description: "If specified, the maximum number of items to return." - required: false - type: "string" - default: "10" - - name: "offset" - in: "query" - description: "If specified, the number of records to skip/offset." - required: false - type: "string" - default: "0" - responses: - "200": - description: "successful operation" - responseSchema: - $ref: "#/definitions/ResponseList" - schema: - $ref: "#/definitions/ResponseList" /api/secure/monitoredtrip/checkitinerary: post: tags: