Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update Camera Serial Numbers #255

Merged
merged 8 commits into from
Sep 18, 2024
11 changes: 11 additions & 0 deletions src/@types/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@ export type Mutation = {
unregisterCamera?: Maybe<UnregisterCameraPayload>;
updateAutomationRules?: Maybe<UpdateAutomationRulesPayload>;
updateBatch?: Maybe<BatchPayload>;
updateCameraSerialNumber?: Maybe<Task>;
updateDeployment?: Maybe<Task>;
updateImageComment?: Maybe<ImageCommentsPayload>;
updateLabels?: Maybe<StandardPayload>;
Expand Down Expand Up @@ -736,6 +737,11 @@ export type MutationUpdateBatchArgs = {
};


export type MutationUpdateCameraSerialNumberArgs = {
input: UpdateCameraSerialNumberInput;
};


export type MutationUpdateDeploymentArgs = {
input: UpdateDeploymentInput;
};
Expand Down Expand Up @@ -1103,6 +1109,11 @@ export type UpdateBatchInput = {
uploadedFile?: InputMaybe<Scalars['String']['input']>;
};

export type UpdateCameraSerialNumberInput = {
cameraId: Scalars['ID']['input'];
newId: Scalars['String']['input'];
};

export type UpdateDeploymentInput = {
cameraId: Scalars['ID']['input'];
deploymentId: Scalars['ID']['input'];
Expand Down
28 changes: 15 additions & 13 deletions src/api/auth/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@ const MANAGER = 'project_manager';
const MEMBER = 'project_member';
// const OBSERVER = 'project_observer';

const EXPORT_DATA_ROLES = [MANAGER, MEMBER];
const WRITE_OBJECTS_ROLES = [MANAGER, MEMBER];
const WRITE_VIEWS_ROLES = [MANAGER, MEMBER];
const WRITE_COMMENTS_ROLES = [MANAGER, MEMBER];
const READ_TASKS_ROLES = [MANAGER, MEMBER];
const WRITE_PROJECT_ROLES = [MANAGER];
const WRITE_IMAGES_ROLES = [MANAGER];
const DELETE_IMAGES_ROLES = [MANAGER];
const MANAGE_USERS_ROLES = [MANAGER];
const WRITE_DEPLOYMENTS_ROLES = [MANAGER];
const WRITE_AUTOMATION_RULES_ROLES = [MANAGER];
const WRITE_CAMERA_REGISTRATION_ROLES = [MANAGER];
const EXPORT_DATA_ROLES = [MANAGER, MEMBER];
const WRITE_OBJECTS_ROLES = [MANAGER, MEMBER];
const WRITE_VIEWS_ROLES = [MANAGER, MEMBER];
const WRITE_COMMENTS_ROLES = [MANAGER, MEMBER];
const READ_TASKS_ROLES = [MANAGER, MEMBER];
const WRITE_PROJECT_ROLES = [MANAGER];
const WRITE_IMAGES_ROLES = [MANAGER];
const DELETE_IMAGES_ROLES = [MANAGER];
const MANAGE_USERS_ROLES = [MANAGER];
const WRITE_DEPLOYMENTS_ROLES = [MANAGER];
const WRITE_AUTOMATION_RULES_ROLES = [MANAGER];
const WRITE_CAMERA_REGISTRATION_ROLES = [MANAGER];
const WRITE_CAMERA_SERIAL_NUMBER_ROLES = [MANAGER];

export {
READ_TASKS_ROLES,
Expand All @@ -27,5 +28,6 @@ export {
WRITE_IMAGES_ROLES,
WRITE_DEPLOYMENTS_ROLES,
WRITE_AUTOMATION_RULES_ROLES,
WRITE_CAMERA_REGISTRATION_ROLES
WRITE_CAMERA_REGISTRATION_ROLES,
WRITE_CAMERA_SERIAL_NUMBER_ROLES,
};
162 changes: 159 additions & 3 deletions src/api/db/models/Camera.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
import mongoose from 'mongoose';
import mongoose, { HydratedDocument } from 'mongoose';
import GraphQLError, { InternalServerError, CameraRegistrationError } from '../../errors.js';
import WirelessCamera, { WirelessCameraSchema } from '../schemas/WirelessCamera.js';
import Images from '../schemas/Image.js';
import retry from 'async-retry';
import { WRITE_CAMERA_REGISTRATION_ROLES } from '../../auth/roles.js';
import {
WRITE_CAMERA_REGISTRATION_ROLES,
WRITE_CAMERA_SERIAL_NUMBER_ROLES,
} from '../../auth/roles.js';
import { ProjectModel } from './Project.js';
import { BaseAuthedModel, MethodParams, roleCheck, idMatch } from './utils.js';
import { Context } from '../../handler.js';
import type * as gql from '../../../@types/graphql.js';
import Project, { ProjectSchema } from '../schemas/Project.js';
import { TaskModel } from './Task.js';
import { TaskSchema } from '../schemas/Task.js';

const ObjectId = mongoose.Types.ObjectId;

Expand Down Expand Up @@ -229,6 +235,151 @@ export class CameraModel {
throw new InternalServerError(err as string);
}
}

static async updateSerialNumberTask(
input: gql.UpdateCameraSerialNumberInput,
context: Pick<Context, 'user' | 'config'>,
): Promise<HydratedDocument<TaskSchema>> {
try {
return await TaskModel.create(
{
type: 'UpdateSerialNumber',
projectId: context.user['curr_project'],
user: context.user.sub,
config: input,
},
context,
);
} catch (err) {
if (err instanceof GraphQLError) throw err;
throw new InternalServerError(err as string);
}
}

// NOTE: this method is called by the async task handler
static async updateSerialNumber(
input: gql.UpdateCameraSerialNumberInput,
context: Pick<Context, 'user'>,
): Promise<gql.StandardPayload> {
const { cameraId, newId } = input;
let successfulOps: OperationMetadata[] = [];
console.log('CameraModel.updateSerialNumber - input: ', input);

try {
// check if it's a wireless camera
const wirelessCamera = await WirelessCamera.findOne({ _id: cameraId });
if (wirelessCamera) {
throw new GraphQLError(`You can't update wireless cameras' serial numbers`);
}

// Step 1: update serial number on all images
// TODO: do we want to wrap this all in a retry?
console.time('update-serial-number-on-images');
const affectedImgIds = (await Images.find({ cameraId }, '_id')).map((img) =>
img._id.toString(),
);
const res = await Images.updateMany({ cameraId }, { cameraId: newId });
console.log('CameraModel.updateSerialNumber() Images.updateMany - res: ', res);
console.timeEnd('update-serial-number-on-images');
if (res.matchedCount !== res.modifiedCount) {
throw new GraphQLError('Not all images were updated successfully');
}
successfulOps.push({
op: 'images-updated',
info: { sourceCamId: input.cameraId, targetCamId: newId, affectedImgIds },
});
console.log('CameraModel.updateSerialNumber() - updated all images');

// Step 2: check if we're merging a source camera into a target camera
const project = await ProjectModel.queryById(context.user['curr_project']);
const sourceCamConfig = project.cameraConfigs.find((cc) => idMatch(cc._id, cameraId));
const isMerge = project.cameraConfigs.some((cc) => idMatch(cc._id, newId));

if (isMerge) {
console.log(
`CameraModel.updateSerialNumber() - merging cameras - source: ${cameraId}, target: ${newId}`,
);
const sourceDeployments =
sourceCamConfig?.deployments.map((dep) => dep._id.toString()) || [];

// Step 3: remove source cameraConfig from Project
console.log('CameraModel.updateSerialNumber() - removing source cameraConfig');
const ccIdx = project.cameraConfigs.findIndex((cc) => idMatch(cc._id, cameraId));
project.cameraConfigs.splice(ccIdx, 1);

// Step 4: re-map images to deployments in target cameraConfig
console.log('CameraModel.updateSerialNumber() - re-mapping images to target cameraConfig');
const targetCamConfig = project.cameraConfigs.find((cc) => idMatch(cc._id, newId));
if (!targetCamConfig) {
throw new GraphQLError(`Could not find cameraConfig for camera ${newId}`);
}
await ProjectModel.reMapImagesToDeps({ projId: project._id, camConfig: targetCamConfig });
successfulOps.push({
op: 'images-remapped-to-target-deps',
info: { sourceCamConfig },
});

// Step 5: remove source deployments from Views
console.log('CameraModel.updateSerialNumber() - removing source deployments from Views');
project.views.forEach((view) => {
console.log('CameraModel.updateSerialNumber() - checking view: ', view.name);
if (view.filters.deployments?.length) {
console.log('CameraModel.updateSerialNumber() - view has deployments');
view.filters.deployments = view.filters.deployments.filter(
(depId) => !sourceDeployments.includes(depId),
);
console.log(
'CameraModel.updateSerialNumber() - updated view.filters.deployments: ',
view.filters.deployments,
);
}
});
} else {
// Step 3: update Project's cameraConfig with new _id
console.log(
'CameraModel.updateSerialNumber() - NOT a merge. Just updating cameraConfig with new _id',
);
if (!sourceCamConfig) {
throw new GraphQLError(`Could not find cameraConfig for camera ${cameraId}`);
}
sourceCamConfig._id = newId;
}

console.log('CameraModel.updateSerialNumber() - saving Project: ', project);
await project.save();
console.log('CameraModel.updateSerialNumber() - updated Project.cameraConfigs');

return { isOk: true };
} catch (err) {
// reverse successful operations
// NOTE: many of the mutations above involve updating the Project document,
// which is the last operation and thus would be the last to fail,
// so we can safely assume that if we're in this catch block,
// the Project document has not been updated
console.log('CameraModel.updateSerialNumber() - caught error: ', err);
for (const op of successfulOps) {
if (op.op === 'images-updated') {
console.log('reversing images-updated operation');
await Images.updateMany(
{ _id: { $in: op.info.affectedImgIds } },
{ cameraId: op.info.sourceCamId },
);
} else if (op.op === 'images-remapped-to-target-deps') {
console.log('reversing images-remapped-to-target-deps operation');
// re-map images back to source cameraConfig deployments
// NOTE: the images that were already associated with the target cameraConfig
// don't need to be re-mapped because their deploymentIds should not have changed
await ProjectModel.reMapImagesToDeps({
projId: context.user['curr_project'],
camConfig: op.info.sourceCamConfig,
});
}
}

if (err instanceof GraphQLError) throw err;
throw new InternalServerError(err as string);
}
}
}

export default class AuthedCameraModel extends BaseAuthedModel {
Expand All @@ -249,9 +400,14 @@ export default class AuthedCameraModel extends BaseAuthedModel {
async unregisterCamera(...args: MethodParams<typeof CameraModel.unregisterCamera>) {
return await CameraModel.unregisterCamera(...args);
}

@roleCheck(WRITE_CAMERA_SERIAL_NUMBER_ROLES)
async updateSerialNumber(...args: MethodParams<typeof CameraModel.updateSerialNumberTask>) {
return await CameraModel.updateSerialNumberTask(...args);
}
}

interface OperationMetadata {
op: string;
info: { cameraId: string };
info: { [key: string]: any };
}
4 changes: 4 additions & 0 deletions src/api/db/models/Image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export class ImageModel {
throw new InternalServerError(err as string);
}
}

/**
* Creates an ImageAttempt record and Image record
* This is called by the image-ingestion lambda when new images are detected
Expand Down Expand Up @@ -626,6 +627,9 @@ export class ImageModel {
let projectId: string = '';

try {
// NOTE: this could probably be optimized to use a single bulkWrite operation
// (see example in createLabels() below), but it's not a high priority since
// but at most this will receive 10 labels at a time, so there's no risk of timeouts
for (const label of input.labels) {
const res = await retry(
async () => {
Expand Down
13 changes: 9 additions & 4 deletions src/api/db/models/Project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import * as gql from '../../../@types/graphql.js';
import { TaskSchema } from '../schemas/Task.js';

// The max number of labeled images that can be deleted
// when removin a label from a project
// when removing a label from a project
const MAX_LABEL_DELETE = 500;

const ObjectId = mongoose.Types.ObjectId;
Expand Down Expand Up @@ -353,6 +353,9 @@ export class ProjectModel {
}
}

// NOTE: this function is only called as part of CRUD ops on deployments,
// or if we are merging one camera into another in updateCameraSerialNumber,
// all of which are themselves called by the async task handler
static async reMapImagesToDeps({
projId,
camConfig,
Expand All @@ -361,6 +364,7 @@ export class ProjectModel {
camConfig: HydratedDocument<CameraConfigSchema>;
}) {
try {
console.time('reMapImagesToDeps');
await retry(
async () => {
// build array of operations from camConfig.deployments:
Expand Down Expand Up @@ -408,6 +412,7 @@ export class ProjectModel {
},
{ retries: 3 },
);
console.timeEnd('reMapImagesToDeps');
} catch (err) {
if (err instanceof GraphQLError) throw err;
throw new InternalServerError(err as string);
Expand All @@ -434,7 +439,7 @@ export class ProjectModel {
}
}

// NOTE: this function is called by the task handler
// NOTE: this function is called by the async task handler
static async createDeployment(
input: gql.CreateDeploymentInput,
context: Pick<Context, 'user'>,
Expand Down Expand Up @@ -488,7 +493,7 @@ export class ProjectModel {
}
}

// NOTE: this function is called by the task handler
// NOTE: this function is called by the async task handler
static async updateDeployment(
input: gql.UpdateDeploymentInput,
context: Pick<Context, 'user'>,
Expand Down Expand Up @@ -548,7 +553,7 @@ export class ProjectModel {
}
}

// NOTE: this function is called by the task handler
// NOTE: this function is called by the async task handler
static async deleteDeployment(
{ cameraId, deploymentId }: gql.DeleteDeploymentInput,
context: Pick<Context, 'user'>,
Expand Down
1 change: 1 addition & 0 deletions src/api/db/schemas/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const TaskSchema = new Schema({
'CreateDeployment',
'UpdateDeployment',
'DeleteDeployment',
'UpdateSerialNumber',
],
},
status: {
Expand Down
8 changes: 8 additions & 0 deletions src/api/resolvers/Mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ export default {
return context.models.Camera.unregisterCamera(input, context);
},

updateCameraSerialNumber: async (
_: unknown,
{ input }: gql.MutationUpdateCameraSerialNumberArgs,
context: Context,
): Promise<gql.Task> => {
return context.models.Camera.updateSerialNumber(input, context);
},

createProject: async (
_: unknown,
{ input }: gql.MutationCreateProjectArgs,
Expand Down
6 changes: 6 additions & 0 deletions src/api/type-defs/inputs/UpdateCameraSerialNumberInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export default /* GraphQL */ `
input UpdateCameraSerialNumberInput {
cameraId: ID!
newId: String!
}
`;
1 change: 1 addition & 0 deletions src/api/type-defs/root/Mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export default /* GraphQL */ `

registerCamera(input: RegisterCameraInput!): RegisterCameraPayload
unregisterCamera(input: UnregisterCameraInput!): UnregisterCameraPayload
updateCameraSerialNumber(input: UpdateCameraSerialNumberInput!): Task

createView(input: CreateViewInput!): CreateViewPayload
updateView(input: UpdateViewInput!): UpdateViewPayload
Expand Down
9 changes: 9 additions & 0 deletions src/task/camera.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { type User } from '../api/auth/authorization.js';
import { CameraModel } from '../api/db/models/Camera.js';
import { type TaskInput } from '../api/db/models/Task.js';
import type * as gql from '../@types/graphql.js';

export async function UpdateSerialNumber(task: TaskInput<gql.UpdateCameraSerialNumberInput>) {
const context = { user: { is_superuser: true, curr_project: task.projectId } as User };
return await CameraModel.updateSerialNumber(task.config, context);
}
Loading
Loading