diff --git a/samples/cext-trey-research-python/.gitignore b/samples/cext-trey-research-python/.gitignore new file mode 100644 index 00000000..54e95f2b --- /dev/null +++ b/samples/cext-trey-research-python/.gitignore @@ -0,0 +1,17 @@ +# TeamsFx files +appManifest/build/ + +# python virtual environment +.venv/ + +# misc +.deployment/ + +# tmp files +__pycache__/ + +#Azurite +_storage_emulator/ + +.env +node_modules \ No newline at end of file diff --git a/samples/cext-trey-research-python/.vscode/extensions.json b/samples/cext-trey-research-python/.vscode/extensions.json new file mode 100644 index 00000000..bf8c33db --- /dev/null +++ b/samples/cext-trey-research-python/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "TeamsDevApp.ms-teams-vscode-extension", + "ms-python.python", + ] +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/.vscode/launch.json b/samples/cext-trey-research-python/.vscode/launch.json new file mode 100644 index 00000000..6d66d8be --- /dev/null +++ b/samples/cext-trey-research-python/.vscode/launch.json @@ -0,0 +1,69 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Launch App (Edge)", + "type": "msedge", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{local:TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&${account-hint}", + "cascadeTerminateToConfigurations": [ + "Python: Run App Locally" + ], + "presentation": { + "group": "all", + "hidden": true + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Launch App (Chrome)", + "type": "chrome", + "request": "launch", + "url": "https://teams.microsoft.com/l/app/${{local:TEAMS_APP_ID}}?installAppPackage=true&webjoin=true&${account-hint}", + "cascadeTerminateToConfigurations": [ + "Python: Run App Locally" + ], + "presentation": { + "group": "all", + "hidden": true + }, + "internalConsoleOptions": "neverOpen" + }, + { + "name": "Python: Run App Locally", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/app.py", + "cwd": "${workspaceFolder}", + "console": "integratedTerminal" + } + ], + "compounds": [ + { + "name": "Debug (Edge)", + "configurations": [ + "Launch App (Edge)", + "Python: Run App Locally" + ], + "preLaunchTask": "Prepare Teams App Resources", + "presentation": { + "group": "all", + "order": 1 + }, + "stopAll": true + }, + { + "name": "Debug (Chrome)", + "configurations": [ + "Launch App (Chrome)", + "Python: Run App Locally" + ], + "preLaunchTask": "Prepare Teams App Resources", + "presentation": { + "group": "all", + "order": 2 + }, + "stopAll": true + } + ] +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/.vscode/settings.json b/samples/cext-trey-research-python/.vscode/settings.json new file mode 100644 index 00000000..b4cf124a --- /dev/null +++ b/samples/cext-trey-research-python/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "debug.onTaskErrors": "abort", + "python.analysis.extraPaths": [ + "./db_setup" + ] +} diff --git a/samples/cext-trey-research-python/.vscode/tasks.json b/samples/cext-trey-research-python/.vscode/tasks.json new file mode 100644 index 00000000..ba79a110 --- /dev/null +++ b/samples/cext-trey-research-python/.vscode/tasks.json @@ -0,0 +1,112 @@ +// This file is automatically generated by Teams Toolkit. +// The teamsfx tasks defined in this file require Teams Toolkit version >= 5.0.0. +// See https://aka.ms/teamsfx-tasks for details on how to customize each task. +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Prepare Teams App Resources", + "dependsOn": [ + "Validate prerequisites", + "npm install", + "Start local tunnel", + "Start Azurite emulator", + "Provision", + "Deploy" + ], + "dependsOrder": "sequence" + }, + { + "type": "shell", + "label": "npm install", + "command": "npm install --no-audit" + }, + { + "label": "Start Azurite emulator", + "type": "shell", + "command": "npm run storage", + "isBackground": true, + "problemMatcher": { + "pattern": [ + { + "regexp": "^.*$", + "file": 0, + "location": 1, + "message": 2 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "Azurite", + "endsPattern": "successfully listening" + } + }, + "options": { + "cwd": "${workspaceFolder}" + }, + "presentation": { + "reveal": "silent" + } + }, + { + // Check all required prerequisites. + // See https://aka.ms/teamsfx-tasks/check-prerequisites to know the details and how to customize the args. + "label": "Validate prerequisites", + "type": "teamsfx", + "command": "debug-check-prerequisites", + "args": { + "prerequisites": [ + "m365Account", // Sign-in prompt for Microsoft 365 account, then validate if the account enables the sideloading permission. + "portOccupancy" // Validate available ports to ensure those debug ones are not occupied. + ], + "portOccupancy": [ + 3978, // app service port + ] + } + }, + { + // Start the local tunnel service to forward public URL to local port and inspect traffic. + // See https://aka.ms/teamsfx-tasks/local-tunnel for the detailed args definitions. + "label": "Start local tunnel", + "type": "teamsfx", + "command": "debug-start-local-tunnel", + "args": { + "type": "dev-tunnel", + "ports": [ + { + "portNumber": 7071, + "protocol": "http", + "access": "public", + "writeToEnvironmentFile": { + "endpoint": "OPENAPI_SERVER_URL", // output tunnel endpoint as BOT_ENDPOINT + "domain": "BOT_DOMAIN" // output tunnel domain as BOT_DOMAIN + } + } + ], + "env": "local" + }, + "isBackground": true, + "problemMatcher": "$teamsfx-local-tunnel-watch" + }, + { + // Create the debug resources. + // See https://aka.ms/teamsfx-tasks/provision to know the details and how to customize the args. + "label": "Provision", + "type": "teamsfx", + "command": "provision", + "args": { + "env": "local" + } + }, + { + // Build project. + // See https://aka.ms/teamsfx-tasks/deploy to know the details and how to customize the args. + "label": "Deploy", + "type": "teamsfx", + "command": "deploy", + "args": { + "env": "local" + } + } + ] +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/README.md b/samples/cext-trey-research-python/README.md new file mode 100644 index 00000000..4b8a36dd --- /dev/null +++ b/samples/cext-trey-research-python/README.md @@ -0,0 +1,170 @@ +# Trey Research Copilot extension (anonymous version) + +Trey Research is a fictitious consulting company that supplies talent in the software and pharmaceuticals industries. The vision for this demo is to show the full potential of Copilot extensions in a relatable business environment. + +> NOTE: The services needed to use this sample are in private preview only + +> NOTE: This version of the Trey Research sample doesn't do authentication, but may be useful for demos +and experimentation. We plan to release an authenticated version shortly. + +### Prompts that work + + * what trey projects am i assigned to? + (NOTE: In this "anonymous" version of the sample, the user is assumed to be consultant "Avery Howard". If Copilot decides to request information using your real name, the request will fail. Unless your name happens to be "Avery Howard".) + * what trey projects is domi working on? + * do we have any trey consultants with azure certifications? + * what trey projects are we doing for relecloud? + * which trey consultants are working with woodgrove bank? + * in trey research, how many hours has avery delivered this month? + * please find a trey consultant with python skills who is available immediately + * are any trey research consultants available who are AWS certified? (multi-parameter!) + * does trey research have any architects with javascript skills? (multi-parameter!) + * what trey research designers are working at woodgrove bank? (multi-parameter!) + * please charge 10 hours to woodgrove bank in trey research (POST request) + * please add sanjay to the contoso project for trey research (POST request with easy to forget entities, hoping to prompt the user; for now they are defaulted) + +Notice that each prompt mentions "trey"; this isn't necessary once you have mentioned Trey in a conversation, but it does help Copilot decide to call your plugin. This is an advantage of Declarative Copilots, where the plugin is explicitly declared and it's not necessary to establish intent to call it. + +If the sample files are installed and accessible to the logged-in user, + + * find my hours spreadsheet and get the hours for woodgrove, then bill the client + * make a list of my projects, then write a summary of each based on the statement of work. + +## Plugin Features + +The sample showcases the following plugin features: + + 1. Declarative Copilot with branding and instructions, access to relevant SharePoint documents and the API plugin + 1. API based plugin works with any platform that supports REST requests + 1. Copilot will construct queries for specific data using GET requests + 1. Copilot updates and adds data using POST requests + 1. Multi-parameter queries to filter results + 1. Show a confirmation card before POSTing data; capture missing parameters + 1. Display rich adaptive cards + +## Setup + +### Prerequisites + +* [Python 3.11.x](https://www.python.org/downloads/) +* [Visual Studio Code](https://code.visualstudio.com/Download) +* [NodeJS 18.x](https://nodejs.org/en/download) +* [Teams Toolkit extension for VS Code](https://marketplace.visualstudio.com/items?itemName=TeamsDevApp.ms-teams-vscode-extension) + NOTE: If you want to build new projects of this nature, you'll need Teams Toolkit v5.6.1-alpha.039039fab.0 or newer +* [Teams Toolkit CLI](https://learn.microsoft.com/microsoftteams/platform/toolkit/teams-toolkit-cli?pivots=version-three) + (`npm install -g @microsoft/teamsapp-cli`) +* (optional) [Postman](https://www.postman.com/downloads/) + +### Setup instructions (one-time setup) + +1. Log into Teams Toolkit using the tenant where you will run the sample. + +1. Ensure the **Python extension** is installed in Visual Studio Code by searching for 'Python' in the Extensions view (Ctrl+Shift+X) and clicking Install. + +1. Press **CTRL+Shift+P** to open the command box and enter **Python: Create Environment** to create and activate your desired virtual environment. + +1. OPTIONAL: Copy the files from the **/sampleDocs** folder to OneDrive or SharePoint. Add the location of these files in the `OneDriveAndSharePoint` capability in the declarative copilot (**/appPackage/trey-declarative-copilot.json**). + +### Running the solution (after each build) + +1. Press F5 to start the application. It will take a while on first run to download the dependencies. Eventually a browser window will open up and your package is installed. + +2. Navigate to Copilot as shown below 1️⃣ +![Running in Copilot](./assets/images/startsample.png) + +3. To use the plugin, open the plugin panel 2️⃣ and enable your plugin 3️⃣. For best results, mention "trey" with each prompt. + +4. To use the declarative Copilot, open the flyout 4️⃣ and select the Trey Genie Local solution 5️⃣. + +## API Summary + +![postman](https://voyager.postman.com/logo/postman-logo-icon-orange.svg) + +We have a [Postman collection](https://documenter.getpostman.com/view/5938178/2sA3JJ8hfn) for you to try out the APIs. It's a great way to get to know the data that Copilot is accessing. + +All API operations are included in the collection, with parameters and body provided to make it easier for you to test our GET and POST calls. + +> Make sure you have [Postman desktop](https://www.postman.com/downloads/) to be able to test urls with `localhost` domain. +Or simply replace part of the URL `http://localhost:7071` with your tunnel/host URL. + +#### GET Requests + +~~~javascript + + GET /api/me/ - get my consulting profile and projects + +GET /api/consultants/ - get all consultants +// Query string params can be used in any combination to filter results +GET /api/consultants/?consultantName=Avery - get consultants with names containing "Avery" +GET /api/consultants/?projectName=Foo - get consultants on projects with "Foo" in the name +GET /api/consultants/?skill=Foo - get consultants with "Foo" in their skills list +GET /api/consultants/?certification=Foo - get consultants with "Foo" in their certifications list +GET /api/consultants/?role=Foo - get consultants who can serve the "Foo" role on a project +GET /api/consultants/?availability=x - get consultants with x hours availability this month or next month + +~~~ + +The above requests all return an array of consultant objects, which are defined in the ApiConsultant interface in /model/apiModel.ts. + +~~~javascript +GET /api/projects/ - get all projects +// Query string params can be used in any combination to filter results +GET /api/projects/?projectName=Foo - get projects with "Foo" in the name +GET /api/projects/?consultantName=Avery - get projects where a consultant containing "Avery" is assigned + +~~~ + +The above requests all return an array of project objects, which are defined in the ApiProject interface in /model/apiModel.ts. + +#### POST Requests + +~~~javascript +POST /api/me/chargeTime - Add hours to project with "Foo" in the name + +Request body: +{ + projectName: "foo", + hours: 5 +} +Response body: +{ + status: 200, + message: "Charged 3 hours to Woodgrove Bank on project \"Financial data plugin for Microsoft Copilot\". You have 17 hours remaining this month"; +} + +POST /api/projects/assignConsultant - Add consultant to project with "Foo" in the name +Request body: +{ + projectName: "foo", + consultantName: "avery", + role: "architect", + forecast: number +} +Response body: +{ + status: 200 + message: "Added Alice to the \"Financial data plugin for Microsoft Copilot\" project at Woodgrove Bank. She has 100 hours remaining this month."; +} +~~~ + +## API Design considerations + +The process began with a bunch of sample prompts that serve as simple use cases for the service. The API is designed specifically to serve those use cases and likely prompts. In order to make it easier for use in the RAG orchestration, the service: + +1. Completes each prompt / use case in a single HTTP request + + * accept names or partial names that might be stated in a user prompt rather than requiring IDs which must be looked up + * return enough information to allow for richer responses; err on the side of providing more detail including related entities + +2. For best Copilot performance, limit the number of parameter options to 10-15 + +3. Ensure that parameters, properties, messages, etc. are human readable, as they will be interpreted by a large language model + +4. Return all the data Copilot might need to fulfull a user prompt. For example, when retrieving a +consultant, the API has no way to know if the user was seeking the consultant's skills, location, project list, or something else. Thus, the API returns all this information. + +5. In GET requests, use the resource that corresponds to the entity the user is asking for. Don't expect Copilot to figure out that some data is buried in another entity. + +6. In POST requests, use a command style such as `/me/chargeTime`, as opposed to asking the API to update a data structure + +7. Don't expect Copilot to filter data; instead provide parameters and filter it server side. (I have seen some filtering by Copilot however - this is for further study) \ No newline at end of file diff --git a/samples/cext-trey-research-python/TreyResearch API.postman_collection.json b/samples/cext-trey-research-python/TreyResearch API.postman_collection.json new file mode 100644 index 00000000..5a4f2140 --- /dev/null +++ b/samples/cext-trey-research-python/TreyResearch API.postman_collection.json @@ -0,0 +1,363 @@ +{ + "info": { + "_postman_id": "32c7d928-e0f9-4061-afd7-9ac1482de262", + "name": "TreyResearch API", + "description": "# 🚀 Get started here\n\nThis template guides you through API operations (GET, POST) available to developers to test out APIs for a consultant and project management company - Trey Research. Trey Research is a fictitious consulting company that supplies talent in the software and pharmaceuticals industries.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "4380975" + }, + "item": [ + { + "name": "Get my consulting profile and projects", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/me/", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "me", + "" + ] + }, + "description": "This API endpoint is a GET request designed to retrieve the consulting profile and data of the logged in user (for now with no auth it is always a mock user Avery). \nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n \"location\": {\n \"street\": \"5 Wayside Rd.\",\n \"city\": \"Burlington\",\n \"state\": \"MA\",\n \"country\": \"USA\",\n \"postalCode\": \"01803\",\n \"latitude\": 42.5048,\n \"longitude\": -71.1956....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get all consultants", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/consultants/", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "consultants", + "" + ] + }, + "description": "This API endpoint is a GET request designed to retrieve all consultants in Trey Research \nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n ....\n },\n {\n \"id\": \"2\",\n \"name\": \"Dominique Dutertre\",\n \"email\": \"dominique@treyresearch.com\",\n \"phone\": \"1-555-567-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Dominique.jpg\",\n ....\n },\n ...\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get consultants with name", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/consultants/?consultantName=Avery", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "consultants", + "" + ], + "query": [ + { + "key": "consultantName", + "value": "Avery" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of consultants whose names, or parts of their names, match a given name. The endpoint uses query parameter `consultantName` to accept a search string, which it then uses to query the database and return matching consultant profiles.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n \"location\": {\n \"street\": \"5 Wayside Rd.\",\n \"city\": \"Burlington\",\n \"state\": \"MA\",\n \"country\": \"USA\",\n \"postalCode\": \"01803\",\n \"latitude\": 42.5048,\n \"longitude\": -71.1956....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get consultants with projects based on projects they are assigned", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/consultants/?projectName=Financial", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "consultants", + "" + ], + "query": [ + { + "key": "projectName", + "value": "Financial" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of consultants who is assigned to projects which has its name or parts of its names, match a given project name. The endpoint uses query parameter `projectName` to accept a search string, which it then uses to query the database and return matching consultant profiles.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n .....\n \"projects\": [\n {\n \"projectName\": \"Financial data plugin for Microsoft Copilot\",\n \"projectDescription\": \"Extend Woodgrove's financial analytics platform to integrate with Microsoft Copilot\",\n \"projectLocation\": {\n \"street\": \"1 Microsoft Way\",\n \"city\": \"Redmond\",\n \"state\": \"WA\",\n \"country\": \"USA\",\n \"postalCode\": \"98052\",\n \"latitude\": 47.6396,\n \"longitude\": -122.1295,\n \"mapUrl\": \"https://dev.virtualearth.net/REST/v1/Imagery/Map/Road/?47.6396,-122.1295mapSize=450,600&pp=47.6396,-122.1295&key=xxxxxxxxxxxxxxxxxxxxxxx\"\n },\n \"clientName\": \"Woodgrove Bank\",\n \"clientContact\": \"Bi Gao\",\n \"clientEmail\": \"bigao@woodgrovebank.com\",\n \"role\": \"Architect\",\n \"forecastThisMonth\": 98,\n \"forecastNextMonth\": 1,\n \"deliveredLastMonth\": 0\n .....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get consultants with a certain skill listed", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/consultants/?skill=TypeScript", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "consultants", + "" + ], + "query": [ + { + "key": "skill", + "value": "TypeScript" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of consultants based on skills in their skill list. The endpoint uses query parameter `skill` to accept a search string, which it then uses to query the database and return matching consultant profiles.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n .....\n \"projects\": [\n \"skills\": [\n \"C#\",\n \"JavaScript\",\n \"TypeScript\",\n \"React\",\n \"Node.js\"\n ],\n \"certifications\": [\n \"MCSADA\",\n \"Azure Developer Associate\",\n \"MCAAF\",\n \"Azure AI Fundamentals\"\n ],\n \"roles\": [\n \"Project lead\",\n \"Developer\",\n \"Architect\",\n \"DevOps\"\n ],\n .....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get consultants with a certain certification listed", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/consultants/?certification=azure", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "consultants", + "" + ], + "query": [ + { + "key": "certification", + "value": "azure" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of consultants based on the certifications they have acquired. The endpoint uses query parameter `certification` to accept a search string, which it then uses to query the database and return matching consultant profiles.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n .....\n \"projects\": [\n \"skills\": [\n \"C#\",\n \"JavaScript\",\n \"TypeScript\",\n \"React\",\n \"Node.js\"\n ],\n \"certifications\": [\n \"MCSADA\",\n \"Azure Developer Associate\",\n \"MCAAF\",\n \"Azure AI Fundamentals\"\n ],\n \"roles\": [\n \"Project lead\",\n \"Developer\",\n \"Architect\",\n \"DevOps\"\n ],\n .....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get consultants with a certain role listed", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/consultants/?role=developer", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "consultants", + "" + ], + "query": [ + { + "key": "role", + "value": "developer" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of consultants based on their roles. The endpoint uses query parameter `role` to accept a search string, which it then uses to query the database and return matching consultant profiles.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n .....\n \"projects\": [\n \"skills\": [\n \"C#\",\n \"JavaScript\",\n \"TypeScript\",\n \"React\",\n \"Node.js\"\n ],\n \"certifications\": [\n \"MCSADA\",\n \"Azure Developer Associate\",\n \"MCAAF\",\n \"Azure AI Fundamentals\"\n ],\n \"roles\": [\n \"Project lead\",\n \"Developer\",\n \"Architect\",\n \"DevOps\"\n ],\n .....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get consultants has x hours available in the current or next month", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/consultants/?availability=2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "consultants", + "" + ], + "query": [ + { + "key": "availability", + "value": "2" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of consultants who have x hours available in the current month or next. The endpoint uses query parameter `availability` to accept the value of `x` hours, which it then uses to query the database and return matching consultant profiles.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"Avery Howard\",\n \"email\": \"avery@treyresearch.com\",\n \"phone\": \"1-555-456-7890\",\n \"imageUrl\": \"https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg\",\n .....\n }\n ],\n \"forecastThisMonth\": 138,\n \"forecastNextMonth\": 41,\n \"deliveredLastMonth\": 0,\n \"deliveredThisMonth\": 2\n .....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get Get all projects", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/projects/", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "projects", + "" + ] + }, + "description": "This API endpoint is a GET request designed to retrieve all projects in Trey Research \nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below: \n\n``` json\n{\n \"results\": [\n {\n \"id\": \"1\",\n \"name\": \"CRM Cloud Migration\",\n \"description\": \"Migrate Adatum's CRM system to Dynamics 365\",\n \"clientName\": \"Adatum Corporation\",\n \"clientContact\": \"Natasha Jones\",\n \"clientEmail\": \"natjones@adatum.com\",\n ....\n \"forecastThisMonth\": 10,\n \"forecastNextMonth\": 0,\n \"deliveredLastMonth\": 0,\n \"deliveredThisMonth\": 0\n },\n {\n \"id\": \"10\",\n \"name\": \"Financial data plugin for Microsoft Copilot\",\n \"description\": \"Extend Woodgrove's financial analytics platform to integrate with Microsoft Copilot\",\n \"clientName\": \"Woodgrove Bank\",\n \"clientContact\": \"Bi Gao\",\n \"clientEmail\": \"bigao@woodgrovebank.com\",\n .....\n \"forecastThisMonth\": 118,\n \"forecastNextMonth\": 21,\n \"deliveredLastMonth\": 0,\n \"deliveredThisMonth\": 2\n },\n .....\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get projects with name", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/projects/?projectName=Financial", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "projects", + "" + ], + "query": [ + { + "key": "projectName", + "value": "Financial" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of project which have name, or parts of its name, matching a given name. The endpoint uses query parameter `projectName` to accept a search string, which it then uses to query the database and return matching projects.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"10\",\n \"name\": \"Financial data plugin for Microsoft Copilot\",\n \"description\": \"Extend Woodgrove's financial analytics platform to integrate with Microsoft Copilot\",\n \"clientName\": \"Woodgrove Bank\",\n \"clientContact\": \"Bi Gao\",\n \"clientEmail\": \"bigao@woodgrovebank.com\",\n \"location\": {\n \"street\": \"1 Microsoft Way\",\n \"city\": \"Redmond\",\n \"state\": \"WA\",\n \"country\": \"USA\",\n \"postalCode\": \"98052\",\n \"latitude\": 47.6396,\n \"longitude\": -122.1295,\n \"mapUrl\": \"https://dev.virtualearth.net/REST/v1/Imagery/Map/Road/?47.6396,-122.1295mapSize=450,600&pp=47.6396,-122.1295&key=xxxxxxxxxxxxxxxxxxxxxxx\"\n },\n ...\n }\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Get projects based on consultant name assigned", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:7071/api/projects/?consultantName=Avery", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "projects", + "" + ], + "query": [ + { + "key": "consultantName", + "value": "Avery" + } + ] + }, + "description": "This API endpoint is a GET request designed to retrieve a list of project which have consultants who's name, or parts of their name, matching a given name. The endpoint uses query parameter `consultantName` to accept a search string, which it then uses to query the database and return matching projects.\n\nWhen a client makes a GET request to this endpoint, they should expect a 200 OK status code upon success, indicating that the request has been processed and the data is being sent in the response. The response body will typically be in JSON format as shown below:\n\n``` json\n{\n \"results\": [\n {\n \"id\": \"10\",\n \"name\": \"Financial data plugin for Microsoft Copilot\",\n \"description\": \"Extend Woodgrove's financial analytics platform to integrate with Microsoft Copilot\",\n \"clientName\": \"Woodgrove Bank\",\n \"clientContact\": \"Bi Gao\",\n \"clientEmail\": \"bigao@woodgrovebank.com\",\n ....\n \"consultants\": [\n {\n \"consultantName\": \"Avery Howard\",\n \"consultantLocation\": {\n \"street\": \"5 Wayside Rd.\",\n \"city\": \"Burlington\",\n \"state\": \"MA\",\n \"country\": \"USA\",\n \"postalCode\": \"01803\",\n \"latitude\": 42.5048,\n \"longitude\": -71.1956,\n \"mapUrl\": \"https://dev.virtualearth.net/REST/v1/Imagery/Map/Road/?42.5048,-71.1956mapSize=450,600&pp=42.5048,-71.1956&key=xxxxxxxxxxxxxxxxxxxxxxx\"\n },\n \"role\": \"Developer\",\n \"forecastThisMonth\": 40,\n \"forecastNextMonth\": 40,\n \"deliveredLastMonth\": 0,\n \"deliveredThisMonth\": 0\n }\n ],\n \"forecastThisMonth\": 40,\n \"forecastNextMonth\": 40,\n \"deliveredLastMonth\": 0,\n \"deliveredThisMonth\": 0\n }\n ]\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Add hours to a project", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"projectName\": \"Woodgrove\",\n \"hours\": 2\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:7071/api/me/chargeTime", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "me", + "chargeTime" + ] + }, + "description": "This API endpoint is a POST request that allows users to log hours spent on a project. The request body must be in JSON format and include the `projectName` and `hours` fields.\n\nWhen a client makes a POST request to this endpoint with the appropriate JSON body, they should expect a 200 Created status code upon successful creation of the log entry.\n\nThe JSON request body should look like this:\n\n``` json\n{\n\"projectName\": \"Woodgrove\",\n\"hours\": 2\n}\n\n ```\n\nAnd its sample response:\n\n``` json\n{\n \"results\": {\n \"status\": 200,\n \"clientName\": \"Woodgrove Bank\",\n \"projectName\": \"Financial data plugin for Microsoft Copilot\",\n \"remainingForecast\": 96,\n \"message\": \"Charged 2 hours to Woodgrove Bank on project \\\"Financial data plugin for Microsoft Copilot\\\". You have 96 hours remaining this month.\"\n }\n}\n\n ```" + }, + "response": [] + }, + { + "name": "Assign a consultant to a project", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"projectName\": \"CRM Cloud Migration\",\n \"consultantName\": \"Lois\",\n \"role\": \"project lead\",\n \"forecast\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:7071/api/projects/assignConsultant", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "7071", + "path": [ + "api", + "projects", + "assignConsultant" + ] + }, + "description": "This API endpoint is a POST request that assigns a consultant to a project with some forcast alloted.\n\nWhen a client makes a POST request to this endpoint with the appropriate JSON body, they should expect a 200 Created status code upon successful creation of the log entry.\n\nThe JSON request body should look like this:\n\n``` json\n{\n \"projectName\": \"CRM Cloud Migration\",\n \"consultantName\": \"Lois\",\n \"role\": \"project lead\",\n \"forecast\": 10\n}\n\n ```\n\nAnd its sample response:\n\n``` json\n{\n \"results\": {\n \"status\": 200,\n \"clientName\": \"Adatum Corporation\",\n \"projectName\": \"CRM Cloud Migration\",\n \"consultantName\": \"Lois Wyn\",\n \"remainingForecast\": 10,\n \"message\": \"Added consultant Lois Wyn to Adatum Corporation on project \\\"CRM Cloud Migration\\\" with 10 hours forecast this month.\"\n }\n}\n\n ```" + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/app.py b/samples/cext-trey-research-python/app.py new file mode 100644 index 00000000..40380b63 --- /dev/null +++ b/samples/cext-trey-research-python/app.py @@ -0,0 +1,40 @@ +import os +import subprocess +import shutil +import sys +from db_setup.azure_table_setup import main as setup_azure_tables +from config import Settings + +CONFIG = Settings() + +# Setup Azure Tables +def setup_tables(): + connection_string = CONFIG.STORAGE_ACCOUNT_CONNECTION_STRING + setup_azure_tables(connection_string, reset=True) # Adjust `reset` as needed + +# Run Azure Functions +def run_azure_functions(): + # Check if 'func' is in PATH + func_path = shutil.which("func") + + if func_path: + # Change directory to where your Azure Functions app is located + os.chdir('functions') # Adjust 'functions' if needed to match your directory structure + + try: + # Run the Azure Functions host using the subprocess module to call 'func start' + subprocess.run([func_path, "start"], shell=True, check=True) + except subprocess.CalledProcessError as e: + print(f"Error occurred while running Azure Functions: {e}") + sys.exit(1) + else: + print("Azure Functions Core Tools (func) not found in your system PATH.") + print("Please install it or ensure it's available in the PATH.") + sys.exit(1) + +if __name__ == "__main__": + # Step 1: Setup Azure Tables + setup_tables() + + # Step 2: Start Azure Functions + run_azure_functions() \ No newline at end of file diff --git a/samples/cext-trey-research-python/appManifest/TreyResearch-blue-192.png b/samples/cext-trey-research-python/appManifest/TreyResearch-blue-192.png new file mode 100644 index 00000000..020a218e Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/TreyResearch-blue-192.png differ diff --git a/samples/cext-trey-research-python/appManifest/TreyResearch-blue-32.png b/samples/cext-trey-research-python/appManifest/TreyResearch-blue-32.png new file mode 100644 index 00000000..f6000dea Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/TreyResearch-blue-32.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-gold-192.png b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-gold-192.png new file mode 100644 index 00000000..27646f62 Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-gold-192.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-gold-32.png b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-gold-32.png new file mode 100644 index 00000000..673535ba Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-gold-32.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-green-192.png b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-green-192.png new file mode 100644 index 00000000..55bece8a Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-green-192.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-green-32.png b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-green-32.png new file mode 100644 index 00000000..e7b44668 Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-green-32.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-red-192.png b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-red-192.png new file mode 100644 index 00000000..55bf1373 Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-red-192.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-red-32.png b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-red-32.png new file mode 100644 index 00000000..a5075803 Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/TreyResearch-red-32.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/color.png b/samples/cext-trey-research-python/appManifest/extra-icons/color.png new file mode 100644 index 00000000..23a2c82a Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/color.png differ diff --git a/samples/cext-trey-research-python/appManifest/extra-icons/outline.png b/samples/cext-trey-research-python/appManifest/extra-icons/outline.png new file mode 100644 index 00000000..245fa194 Binary files /dev/null and b/samples/cext-trey-research-python/appManifest/extra-icons/outline.png differ diff --git a/samples/cext-trey-research-python/appManifest/manifest.json b/samples/cext-trey-research-python/appManifest/manifest.json new file mode 100644 index 00000000..2b70e4fe --- /dev/null +++ b/samples/cext-trey-research-python/appManifest/manifest.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.19/MicrosoftTeams.schema.json", + "manifestVersion": "1.19", + "id": "${{TEAMS_APP_ID}}", + "version": "1.0.0", + "developer": { + "name": "Teams App, Inc.", + "websiteUrl": "https://www.example.com", + "privacyUrl": "https://www.example.com/privacy", + "termsOfUseUrl": "https://www.example.com/termsofuse" + }, + "icons": { + "color": "TreyResearch-blue-192.png", + "outline": "TreyResearch-blue-32.png" + }, + "name": { + "short": "Trey Research ${{APP_NAME_SUFFIX}}", + "full": "Trey Research consulting management" + }, + "description": { + "short": "Everyday management of Trey Research projects and consultants", + "full": "This application allows you to find Trey Research projects and consultants, to bill time to a project, and to assign a consultant to a project." + }, + "accentColor": "#FFFFFF", + "copilotAgents": { + "declarativeAgents": [ + { + "id": "treygenie", + "file": "trey-declarative-agent.json" + } + ], + "plugins": [ + { + "id": "treyresearch", + "file": "trey-plugin.json" + } + ] + }, + "permissions": [ + "identity", + "messageTeamMembers" + ] +} diff --git a/samples/cext-trey-research-python/appManifest/trey-declarative-agent.json b/samples/cext-trey-research-python/appManifest/trey-declarative-agent.json new file mode 100644 index 00000000..a87b123c --- /dev/null +++ b/samples/cext-trey-research-python/appManifest/trey-declarative-agent.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://aka.ms/json-schemas/copilot/declarative-agent/v1.0/schema.json", + "version": "v1.0", + "name": "Trey Genie Local", + "description": "You are a handy assistant for consultants at Trey Research, a boutique consultancy specializing in software development and clinical trials. ", + "instructions": "Greet users in a professional manner, introduce yourself as the Trey Genie, and offer to help them. Always remind users of the Trey motto, 'Always be Billing!'. Your main job is to help consultants with their projects and hours. Using the TreyResearch action, you are able to find consultants based on their names, project assignments, skills, roles, and certifications. You can also find project details based on the project or client name, charge hours on a project, and add a consultant to a project. If a user asks how many hours they have billed, charged, or worked on a project, reword the request to ask how many hours they have delivered. In addition, you may offer general consulting advice. If there is any confusion, encourage users to speak with their Managing Consultant. Avoid giving legal advice.", + "conversation_starters": [ + { + "title": "My Projects", + "text": "What trey projects am I assigned to?" + }, + { + "title": "My Hours", + "text": "How many hours have I delivered on Trey projects this month?" + }, + { + "title": "Find consultants", + "text": "Find trey consultants with a particular skill" + } + ], + "capabilities": [ + { + "name": "OneDriveAndSharePoint", + "items_by_url": [ + { + "url": "https://${{TENANT_NAME}}.sharepoint.com/sites/TreyResearchLegalDocuments" + } + ] + } + ], + "actions": [ + { + "id": "treyresearch", + "file": "trey-plugin.json" + } + ] +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/appManifest/trey-definition.json b/samples/cext-trey-research-python/appManifest/trey-definition.json new file mode 100644 index 00000000..26d93741 --- /dev/null +++ b/samples/cext-trey-research-python/appManifest/trey-definition.json @@ -0,0 +1,646 @@ +{ + "openapi": "3.0.1", + "info": { + "version": "1.0.0", + "title": "Trey Research API", + "description": "API to streamline consultant assignment and project management." + }, + "servers": [ + { + "url": "${{OPENAPI_SERVER_URL}}/api/", + "description": "Production server" + } + ], + "paths": { + "/consultants/": { + "get": { + "operationId": "getConsultants", + "summary": "Get consultants working at Trey Research based on consultant name, project name, certifications, skills, roles and hours available", + "description": "Returns detailed information about consultants identified from filters like name of the consultant, name of project, certifications, skills, roles and hours available. Multiple filters can be used in combination to refine the list of consultants returned", + "parameters": [ + { + "name": "consultantName", + "in": "query", + "description": "Name of the consultant to retrieve", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "projectName", + "in": "query", + "description": "The name of the project", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "skill", + "in": "query", + "description": "Skills for a consultant. Retrieve consultants with this skill", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "certification", + "in": "query", + "description": "Certification for a consultant. Retrieve consultants with this certification", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "role", + "in": "query", + "description": "Role of a consultant. Retrieve consultants with this role", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "hoursAvailable", + "in": "query", + "description": "Hours a consultant is available for new work over this and next month. Please provide an integer value; for example 0 if no hours are available, 1 if any hours are available, or 20 if 20 or more hours are available.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "phone": { + "type": "string" + }, + "location": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + } + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "certifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "consultantPhotoUrl": { + "type": "string", + "format": "uri" + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectName": { + "type": "string" + }, + "projectDescription": { + "type": "string" + }, + "projectLocation": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + } + }, + "mapUrl": { + "type": "string", + "format": "uri" + }, + "role": { + "type": "string" + }, + "forecastThisMonth": { + "type": "integer" + }, + "forecastNextMonth": { + "type": "integer" + }, + "deliveredLastMonth": { + "type": "integer" + }, + "deliveredThisMonth": { + "type": "integer" + } + } + } + }, + "forecastThisMonth": { + "type": "integer" + }, + "forecastNextMonth": { + "type": "integer" + }, + "deliveredLastMonth": { + "type": "integer" + }, + "deliveredThisMonth": { + "type": "integer" + } + } + } + }, + "status": { + "type": "integer" + } + } + } + } + } + }, + "404": { + "description": "Consultant not found" + } + } + } + }, + "/me": { + "get": { + "operationId": "getUserInformation", + "summary": "Get consultant profile of the logged in user.", + "description": "Retrieve the consultant profile for the logged-in user including skills, roles, certifications, location, availability, and project assignments.", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "phone": { + "type": "string" + }, + "location": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + } + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "certifications": { + "type": "array", + "items": { + "type": "string" + } + }, + "roles": { + "type": "array", + "items": { + "type": "string" + } + }, + "consultantPhotoUrl": { + "type": "string", + "format": "uri" + }, + "projects": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectName": { + "type": "string" + }, + "projectDescription": { + "type": "string" + }, + "projectLocation": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + } + }, + "mapUrl": { + "type": "string", + "format": "uri" + }, + "role": { + "type": "string" + }, + "forecastThisMonth": { + "type": "integer" + }, + "forecastNextMonth": { + "type": "integer" + }, + "deliveredLastMonth": { + "type": "integer" + }, + "deliveredThisMonth": { + "type": "integer" + } + } + } + }, + "forecastThisMonth": { + "type": "integer" + }, + "forecastNextMonth": { + "type": "integer" + }, + "deliveredLastMonth": { + "type": "integer" + }, + "deliveredThisMonth": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } + } + }, + "/projects/": { + "get": { + "operationId": "getProjects", + "summary": "Get projects matching a specified project name and/or consultant name", + "description": "Returns detailed information about projects matching the specified project name and/or consultant name", + "parameters": [ + { + "name": "consultantName", + "in": "query", + "description": "The name of the consultant assigned to the project", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "projectName", + "in": "query", + "description": "The name of the project or name of the client", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "location": { + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "country": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "latitude": { + "type": "number" + }, + "longitude": { + "type": "number" + } + } + }, + "mapUrl": { + "type": "string", + "format": "uri" + }, + "role": { + "type": "string" + }, + "forecastThisMonth": { + "type": "integer" + }, + "forecastNextMonth": { + "type": "integer" + }, + "deliveredLastMonth": { + "type": "integer" + }, + "deliveredThisMonth": { + "type": "integer" + } + } + } + }, + "status": { + "type": "integer" + } + } + } + } + } + }, + "404": { + "description": "Project not found" + } + } + } + }, + "/me/chargeTime": { + "post": { + "operationId": "postBillhours", + "summary": "Charge time to a project on behalf of the logged in user.", + "description": "Charge time to a specific project on behalf of the logged in user, and return the number of hours remaining in their forecast.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "projectName": { + "type": "string" + }, + "hours": { + "type": "integer" + } + }, + "required": [ + "projectName", + "hours" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful charge", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "object", + "properties": { + "status": { + "type": "integer" + }, + "clientName": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "remainingForecast": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + }, + "/projects/assignConsultant": { + "post": { + "operationId": "postAssignConsultant", + "summary": "Assign consultant to a project when name, role and project name is specified.", + "description": "Assign (add) consultant to a project when name, role and project name is specified.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "projectName": { + "type": "string" + }, + "consultantName": { + "type": "string" + }, + "role": { + "type": "string" + }, + "forecast": { + "type": "integer" + } + }, + "required": [ + "projectName", + "consultantName", + "role", + "forecast" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful assignment", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "results": { + "type": "object", + "properties": { + "status": { + "type": "integer" + }, + "clientName": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "consultantName": { + "type": "string" + }, + "remainingForecast": { + "type": "integer" + }, + "message": { + "type": "string" + } + } + }, + "status": { + "type": "integer" + } + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/appManifest/trey-plugin.json b/samples/cext-trey-research-python/appManifest/trey-plugin.json new file mode 100644 index 00000000..f38fd2b1 --- /dev/null +++ b/samples/cext-trey-research-python/appManifest/trey-plugin.json @@ -0,0 +1,500 @@ +{ + "schema_version": "v2.1", + "name_for_human": "Trey", + "description_for_human": "API to streamline consultant assignment and project management.", + "namespace": "trey", + "functions": [ + { + "name": "getConsultants", + "description": "Returns detailed information about consultants identified from filters like name of the consultant, name of project, certifications, skills, roles and hours available. Multiple filters can be used in combination to refine the list of consultants returned", + "capabilities": { + "response_semantics": { + "data_path": "$.results", + "properties": { + "title": "$.name", + "subtitle": "$.id", + "url": "$.consultantPhotoUrl" + }, + "static_template": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "Container", + "$data": "${$root}", + "items": [ + { + "speak": "${name}", + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "text": "${name}", + "weight": "bolder", + "size": "extraLarge", + "spacing": "none", + "wrap": true, + "style": "heading" + }, + { + "type": "TextBlock", + "text": "${email}", + "wrap": true, + "spacing": "none" + }, + { + "type": "TextBlock", + "text": "${phone}", + "wrap": true, + "spacing": "none" + }, + { + "type": "TextBlock", + "text": "${location.city}, ${location.country}", + "wrap": true + } + ] + }, + { + "type": "Column", + "items": [ + { + "type": "Image", + "url": "${consultantPhotoUrl}", + "altText": "${name}" + } + ] + } + ] + } + ] + } + + ] + } + } + } + }, + { + "name": "getUserInformation", + "description": "Retrieve the consultant profile for the logged-in user including skills, roles, certifications, location, availability, and project assignments.", + "capabilities": { + "response_semantics": { + "data_path": "$.results", + "properties": { + "title": "$.name", + "subtitle": "$.id", + "url": "$.consultantPhotoUrl" + }, + "static_template":{ + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "Container", + "$data": "${$root}", + "items": [ + { + "speak": "${name}", + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "text": "${name}", + "weight": "bolder", + "size": "extraLarge", + "spacing": "none", + "wrap": true, + "style": "heading" + }, + { + "type": "TextBlock", + "text": "${email}", + "wrap": true, + "spacing": "none" + }, + { + "type": "TextBlock", + "text": "${phone}", + "wrap": true, + "spacing": "none" + }, + { + "type": "TextBlock", + "text": "${location.city}, ${location.country}", + "wrap": true + } + ] + }, + { + "type": "Column", + "items": [ + { + "type": "Image", + "url": "${consultantPhotoUrl}", + "altText": "${name}" + } + ] + } + ] + } + ] + } + + ] + } + } + } + }, + { + "name": "getProjects", + "description": "Returns detailed information about projects matching the specified project name and/or consultant name", + "capabilities": { + "response_semantics": { + "data_path": "$.results", + "properties": { + "title": "$.name", + "subtitle": "$.description" + }, + "static_template": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.5", + "body": [ + { + "type": "Container", + "$data": "${$root}", + "items": [ + { + "speak": "${description}", + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "items": [ + { + "type": "TextBlock", + "text": "${name}", + "weight": "bolder", + "size": "extraLarge", + "spacing": "none", + "wrap": true, + "style": "heading" + }, + { + "type": "TextBlock", + "text": "${description}", + "wrap": true, + "spacing": "none" + }, + { + "type": "TextBlock", + "text": "${location.city}, ${location.country}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "${clientName}", + "weight": "Bolder", + "size": "Large", + "spacing": "Medium", + "wrap": true, + "maxLines": 3 + }, + { + "type": "TextBlock", + "text": "${clientContact}", + "size": "small", + "wrap": true + }, + { + "type": "TextBlock", + "text": "${clientEmail}", + "size": "small", + "wrap": true + } + ] + }, + { + "type": "Column", + "items": [ + { + "type": "Image", + "url": "${mapUrl}", + "altText": "${location.street}" + } + ] + } + ] + } + ] + }, + { + "type": "TextBlock", + "text": "Project Metrics", + "weight": "Bolder", + "size": "Large", + "spacing": "Medium", + "horizontalAlignment": "Center", + "separator": true + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Forecast This Month", + "weight": "Bolder", + "spacing": "Small", + "horizontalAlignment": "Center" + }, + { + "type": "TextBlock", + "text": "${forecastThisMonth} ", + "size": "ExtraLarge", + "weight": "Bolder", + "horizontalAlignment": "Center" + } + ] + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Forecast Next Month", + "weight": "Bolder", + "spacing": "Small", + "horizontalAlignment": "Center" + }, + { + "type": "TextBlock", + "text": "${forecastNextMonth} ", + "size": "ExtraLarge", + "weight": "Bolder", + "horizontalAlignment": "Center" + } + ] + } + ] + }, + { + "type": "ColumnSet", + "columns": [ + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Delivered Last Month", + "weight": "Bolder", + "spacing": "Small", + "horizontalAlignment": "Center" + }, + { + "type": "TextBlock", + "text": "${deliveredLastMonth} ", + "size": "ExtraLarge", + "weight": "Bolder", + "horizontalAlignment": "Center" + } + ] + }, + { + "type": "Column", + "width": "stretch", + "items": [ + { + "type": "TextBlock", + "text": "Delivered This Month", + "weight": "Bolder", + "spacing": "Small", + "horizontalAlignment": "Center" + }, + { + "type": "TextBlock", + "text": "${deliveredThisMonth} ", + "size": "ExtraLarge", + "weight": "Bolder", + "horizontalAlignment": "Center" + } + ] + } + ] + } + ], + "actions": [ + { + "type": "Action.OpenUrl", + "title": "View map", + "url": "${mapUrl}" + } + ] + } + } + } + }, + { + "name": "postBillhours", + "description": "Charge time to a specific project on behalf of the logged in user, and return the number of hours remaining in their forecast.", + "capabilities": { + "response_semantics": { + "data_path": "$", + "properties": { + "title": "$.results.clientName", + "subtitle": "$.results.status" + }, + "static_template": { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "results.status: ${if(results.status, results.status, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.clientName: ${if(results.clientName, results.clientName, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.projectName: ${if(results.projectName, results.projectName, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.remainingForecast: ${if(results.remainingForecast, results.remainingForecast, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.message: ${if(results.message, results.message, 'N/A')}", + "wrap": true + } + ] + } + }, + "confirmation": { + "type": "AdaptiveCard", + "title": "Charge time to a project on behalf of the logged in user.", + "body": "* **ProjectName**: {{function.parameters.projectName}}\n* **Hours**: {{function.parameters.hours}}" + } + } + }, + { + "name": "postAssignConsultant", + "description": "Assign (add) consultant to a project when name, role and project name is specified.", + "capabilities": { + "response_semantics": { + "data_path": "$", + "properties": { + "title": "$.results.clientName", + "subtitle": "$.results.status" + }, + "static_template": { + "type": "AdaptiveCard", + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "version": "1.5", + "body": [ + { + "type": "TextBlock", + "text": "results.status: ${if(results.status, results.status, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.clientName: ${if(results.clientName, results.clientName, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.projectName: ${if(results.projectName, results.projectName, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.consultantName: ${if(results.consultantName, results.consultantName, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.remainingForecast: ${if(results.remainingForecast, results.remainingForecast, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "results.message: ${if(results.message, results.message, 'N/A')}", + "wrap": true + }, + { + "type": "TextBlock", + "text": "status: ${if(status, status, 'N/A')}", + "wrap": true + } + ] + } + }, + "confirmation": { + "type": "AdaptiveCard", + "title": "Assign consultant to a project when name, role and project name is specified.", + "body": "* **ProjectName**: {{function.parameters.projectName}}\n* **ConsultantName**: {{function.parameters.consultantName}}\n* **Role**: {{function.parameters.role}}\n* **Forecast**: {{function.parameters.forecast}}" + } + } + } + ], + "runtimes": [ + { + "type": "OpenApi", + "auth": { + "type": "None" + }, + "spec": { + "url": "trey-definition.json" + }, + "run_for_functions": [ + "getConsultants", + "getUserInformation", + "getProjects", + "postBillhours", + "postAssignConsultant" + ] + } + ], + "capabilities": { + "localization": {}, + "conversation_starters": [ + { + "text": "What Trey projects am i assigned to?" + }, + { + "text": "Charge 5 hours to the Contoso project for Trey Research" + }, + { + "text": "Which Trey consultants are Azure certified?" + }, + { + "text": "Find a Trey consultant who is available now and has Python skills" + }, + { + "text": "Add Avery as a developer on the Contoso project for Trey" + } + ] + } +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/assets/images/startsample.png b/samples/cext-trey-research-python/assets/images/startsample.png new file mode 100644 index 00000000..aa1dd0b4 Binary files /dev/null and b/samples/cext-trey-research-python/assets/images/startsample.png differ diff --git a/samples/cext-trey-research-python/config/__init__.py b/samples/cext-trey-research-python/config/__init__.py new file mode 100644 index 00000000..a0a1d28d --- /dev/null +++ b/samples/cext-trey-research-python/config/__init__.py @@ -0,0 +1,3 @@ +from .settings import Settings + +__all__ = ['Settings'] \ No newline at end of file diff --git a/samples/cext-trey-research-python/config/settings.py b/samples/cext-trey-research-python/config/settings.py new file mode 100644 index 00000000..aabbda49 --- /dev/null +++ b/samples/cext-trey-research-python/config/settings.py @@ -0,0 +1,4 @@ +import os + +class Settings: + STORAGE_ACCOUNT_CONNECTION_STRING = os.environ.get("STORAGE_ACCOUNT_CONNECTION_STRING", "<>") \ No newline at end of file diff --git a/samples/cext-trey-research-python/db_setup/azure_table_setup.py b/samples/cext-trey-research-python/db_setup/azure_table_setup.py new file mode 100644 index 00000000..51450900 --- /dev/null +++ b/samples/cext-trey-research-python/db_setup/azure_table_setup.py @@ -0,0 +1,82 @@ + +import os +import sys +import json +import time +import uuid +from azure.data.tables import TableServiceClient, TableClient +from pathlib import Path + +TABLE_NAMES = ["Project", "Consultant", "Assignment"] + +def get_tables(table_service_client): + tables = [] + table_iter = table_service_client.list_tables() + for table in table_iter: + tables.append(table.name) + return tables + +def delete_tables(table_service_client, connection_string): + tables = get_tables(table_service_client) + for table in tables: + table_client = TableClient.from_connection_string(conn_str=connection_string, table_name=table) + print(f"Deleting table: {table}") + table_client.delete_table() + + while True: + print("Waiting for tables to be deleted...") + tables = get_tables(table_service_client) + if not tables: + print("All tables deleted.") + break + time.sleep(1) + +def create_and_populate_tables(table_service_client, connection_string): + for table_name in TABLE_NAMES: + tables = get_tables(table_service_client) + if table_name in tables: + print(f"Table {table_name} already exists, skipping...") + continue + + print(f"Creating table: {table_name}") + table_created = False + while not table_created: + try: + table_service_client.create_table(table_name=table_name) + table_created = True + except Exception as e: + if '409' in str(e): + print('Table is marked for deletion, retrying in 5 seconds...') + time.sleep(5) + else: + raise e + + table_client = TableClient.from_connection_string(conn_str=connection_string, table_name=table_name) + json_file_path = Path(__file__).resolve().parent / "db" / f"{table_name}.json" + with open(json_file_path, "r", encoding="utf8") as file: + entities = json.load(file) + + for entity in entities["rows"]: + row_key = str(entity.get("id", uuid.uuid4())) + # Convert any nested objects to JSON strings + for key, value in entity.items(): + if isinstance(value, (dict, list)): + entity[key] = json.dumps(value) + + table_client.create_entity({ + 'PartitionKey': table_name, + 'RowKey': row_key, + **entity + }) + print(f"Added entity to {table_name} with key {row_key}") + +def main(connection_string="UseDevelopmentStorage=true", reset=False): + table_service_client = TableServiceClient.from_connection_string(conn_str=connection_string) + + if reset: + delete_tables(table_service_client, connection_string) + + create_and_populate_tables(table_service_client, connection_string) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/cext-trey-research-python/db_setup/db/Assignment.json b/samples/cext-trey-research-python/db_setup/db/Assignment.json new file mode 100644 index 00000000..84ca9525 --- /dev/null +++ b/samples/cext-trey-research-python/db_setup/db/Assignment.json @@ -0,0 +1,158 @@ +{ + "rows": [ + { + "id": "10,1", + "projectId": "10", + "consultantId": "1", + "role": "Architect", + "billable": true, + "rate": 100, + "forecast": [ + { + "month": 3, + "year": 2024, + "hours": 23 + }, + { + "month": 4, + "year": 2024, + "hours": 80 + }, + { + "month": 5, + "year": 2024, + "hours": 100 + }, + { + "month": 6, + "year": 2024, + "hours": 1 + }, + { + "month": 7, + "year": 2024, + "hours": 120 + }, + { + "month": 8, + "year": 2024, + "hours": 120 + }, + { + "month": 9, + "year": 2024, + "hours": 120 + }, + { + "month": 10, + "year": 2024, + "hours": 120 + } + ], + "delivered": [ ] + }, + { + "id": "10,2", + "projectId": "10", + "consultantId": "2", + "role": "Designer", + "billable": true, + "rate": 100, + "forecast": [ + { + "month": 3, + "year": 2024, + "hours": 100 + }, + { + "month": 4, + "year": 2024, + "hours": 20 + }, + { + "month": 5, + "year": 2024, + "hours": 20 + }, + { + "month": 6, + "year": 2024, + "hours": 20 + }, + { + "month": 7, + "year": 2024, + "hours": 20 + }, + { + "month": 8, + "year": 2024, + "hours": 10 + }, + { + "month": 9, + "year": 2024, + "hours": 10 + }, + { + "month": 10, + "year": 2024, + "hours": 10 + } + ], + "delivered": [ ] + }, + { + "id": "4,1", + "projectId": "4", + "consultantId": "1", + "role": "Developer", + "billable": true, + "rate": 100, + "forecast": [ + { + "month": 3, + "year": 2024, + "hours": 40 + }, + { + "month": 4, + "year": 2024, + "hours": 40 + }, + { + "month": 5, + "year": 2024, + "hours": 40 + }, + { + "month": 6, + "year": 2024, + "hours": 40 + }, + { + "month": 7, + "year": 2024, + "hours": 40 + }, + { + "month": 8, + "year": 2024, + "hours": 40 + }, + { + "month": 9, + "year": 2024, + "hours": 40 + }, + { + "month": 10, + "year": 2024, + "hours": 40 + } + ], + "delivered": [ ] + } + ] +} + diff --git a/samples/cext-trey-research-python/db_setup/db/Consultant.json b/samples/cext-trey-research-python/db_setup/db/Consultant.json new file mode 100644 index 00000000..2b184a6e --- /dev/null +++ b/samples/cext-trey-research-python/db_setup/db/Consultant.json @@ -0,0 +1,99 @@ +{ + "rows": [ + { + "id": "1", + "name": "Avery Howard", + "email": "avery@treyresearch.com", + "phone": "1-555-456-7890", + "consultantPhotoUrl": "https://bobgerman.github.io/fictitiousAiGenerated/Avery.jpg", + "location": { + "street": "5 Wayside Rd.", + "city": "Burlington", + "state": "MA", + "country": "USA", + "postalCode": "01803", + "latitude": 42.5048, + "longitude": -71.1956 + }, + "skills": [ "C#", "JavaScript", "TypeScript", "React", "Node.js" ], + "certifications": [ "MCSADA", "Azure Developer Associate", "MCAAF", "Azure AI Fundamentals" ], + "roles": [ "Project lead", "Developer", "Architect", "DevOps" ] + }, + { + "id": "2", + "name": "Dominique Dutertre", + "email": "dominique@treyresearch.com", + "phone": "1-555-567-7890", + "consultantPhotoUrl": "https://bobgerman.github.io/fictitiousAiGenerated/Dominique.jpg", + "location": { + "street": "39 Quai du Président Roosevelt, Issy-les-Moulineaux", + "city": "Paris", + "state": "Île-de-France", + "country": "France", + "postalCode": "IdF 92130", + "latitude": 48.8264, + "longitude": 2.2675 + }, + "skills": [ "Figma", "Adobe Creative Swuite", "Visual design" ], + "certifications": [ ], + "roles": [ "Designer", "Project lead" ] + }, + { + "id": "3", + "name": "Robin Zupanc", + "email": "robin@treyresearch.com", + "phone": "1-555-344-7890", + "consultantPhotoUrl": "https://bobgerman.github.io/fictitiousAiGenerated/Robin.jpg", + "location": { + "street": "11 Times Square", + "city": "New York", + "state": "NY", + "country": "USA", + "postalCode": "10036", + "latitude": 40.7580, + "longitude": -73.9855 + }, + "skills": [ "Python", "Java" ], + "certifications": [ "MCSADA", "Azure Developer Associate", "MCAAF", "Azure AI Fundamentals", "AWS Certified Developer", "Google Professional Cloud Developer" ], + "roles": [ "Project lead", "Developer", "Architect, DevOps" ] + }, + { + "id": "4", + "name": "Sanjay Puranik", + "email": "sanjay@treyresearch.com", + "phone": "1-555-562-7890", + "consultantPhotoUrl": "https://bobgerman.github.io/fictitiousAiGenerated/Sanjay.jpg", + "location": { + "street": "2 Kingdom St.", + "city": "Paddington, London", + "state": "EN", + "country": "GBR", + "postalCode": "W2 6BD", + "latitude": 51.5194, + "longitude": -0.1781 + }, + "skills": [ "TypeScript", "JavaScript", "Angular", "React", "Node.js" ], + "certifications": [ "MCPPSAE", "Power Platform Solution Architect Expert", "MCAF", "Azure Fundamentals" ], + "roles": [ "Project lead", "Developer" ] + }, + { + "id": "5", + "name": "Lois Wyn", + "email": "lois@treyresearch.com", + "phone": "1-555-664-7890", + "consultantPhotoUrl": "https://bobgerman.github.io/fictitiousAiGenerated/Lois.jpg", + "location": { + "street": "4220 Duncan Ave., Suite 501", + "city": "St. Louis", + "state": "MO", + "country": "USA", + "postalCode": "63110", + "latitude": 38.6283, + "longitude": -90.2545 + }, + "skills": [ "Project management", "Agile" ], + "certifications": [ "Project Management Professional", "PMP", "Agile Leader" ], + "roles": [ "Project lead" ] + } + ] +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/db_setup/db/Project.json b/samples/cext-trey-research-python/db_setup/db/Project.json new file mode 100644 index 00000000..6eacbef6 --- /dev/null +++ b/samples/cext-trey-research-python/db_setup/db/Project.json @@ -0,0 +1,174 @@ +{ + "rows": [ + { + "id": "1", + "name": "CRM Cloud Migration", + "description": "Migrate Adatum's CRM system to Dynamics 365", + "clientName": "Adatum Corporation", + "clientContact": "Natasha Jones", + "clientEmail": "natjones@adatum.com", + "location": { + "street": "700 Bellevue Way NE - 22nd Floor", + "city": "Bellevue", + "state": "WA", + "country": "USA", + "postalCode": "98004", + "latitude": 47.617068, + "longitude": -122.200898 + } + }, + { + "id": "2", + "name": "Supply Chain Optimization", + "description": "Develop supply chain automation solution in Azure", + "clientName": "Alpine Ski House", + "clientContact": "Felix Henderson", + "clientEmail": "fhenderson@alpineskihouse.com", + "location": { + "street": "3601 West 76th Street, Suite 600", + "city": "Edina", + "state": "MN", + "country": "USA", + "postalCode": "55435", + "latitude": 44.877582, + "longitude": -93.328504 + } + }, + { + "id": "3", + "name": "Network security review", + "description": "Review campus network security and develop a plan to mitigate vulnerabilities", + "clientName": "Bellows College", + "clientContact": "Jordan Mitchell", + "clientEmail": "jordanm@bellowscollege", + "location": { + "street": "1 Memorial Dr.", + "city": "Cambridge", + "state": "MA", + "country": "USA", + "postalCode": "02142", + "latitude": 42.3626, + "longitude": -71.0843 + } + }, + { + "id": "4", + "name": "Copilot Development and Training", + "description": "Extend LOB applications to integrate with Microsoft Copilot and train key personnel on its use", + "clientName": "Consolidated Messenger", + "clientContact": "Camelia Banica", + "clientEmail": "cambanica@consolidatedmessenger.com", + "location": { + "street": "Regeringsgatan 25", + "city": "Stockholm", + "state": "Södermanland", + "country": "SWE", + "postalCode": "111 53 Stockholm", + "latitude": 59.3293, + "longitude": 18.0686 + } + }, + { + "id": "5", + "name": "Project Alpha Clinical Trial Inspection Reporting", + "description": "Review and report on results from Project Alpha 2nd phase clinical trials", + "clientName": "Contoso Pharmaceuticals", + "clientContact": "Kiana Anderson", + "clientEmail": "kianaa@contoso.com", + "location": { + "street": "1 Campus Martius", + "city": "Detroit", + "state": "MI", + "country": "USA", + "postalCode": "48226", + "latitude": 42.3314, + "longitude": -83.0458 + } + }, + { + "id": "6", + "name": "Cybersecurity Awareness Program", + "description": "Develop and deliver a cybersecurity awareness program for Humongous Insurance employees", + "clientName": "Humongous Insurance", + "clientContact": "Parker McLean", + "clientEmail": "parkerm@humongousinsurance.com", + "location": { + "street": "280 Trumbull Street, 21st floor", + "city": "Hartford", + "state": "CT", + "country": "USA", + "postalCode": "06103", + "latitude": 41.7658, + "longitude": -72.6734 + } + }, + { + "id": "7", + "name": "Patient data retention SOP analysis and update", + "description": "Review existing standard operating procedures for patient data retention and update as necessary", + "clientName": "Lamna Healthcare Company", + "clientContact": "Nayan Mittal", + "clientEmail": "nmittal@lamnahealthcare.com", + "location": { + "street": "45 Liberty Blvd.", + "city": "Malvern", + "state": "PA", + "country": "USA", + "postalCode": "19355", + "latitude": 40.0412, + "longitude": -75.5336 + } + }, + { + "id": "8", + "name": "Disaster Recovery Planning", + "description": "Update risk assessment and develop a disaster recovery plan for Relecloud's datacenter operations", + "clientName": "Relecloud", + "clientContact": "Shakti Menon", + "clientEmail": "shakti@relecloud.com", + "location": { + "street": "6/18, Bellandur Gate Rd.", + "city": "Bengaluru", + "state": "Karnataka", + "country": "India", + "postalCode": "56103", + "latitude": 12.9716, + "longitude": 77.5946 + } + }, + { + "id": "9", + "name": "Video distribution platform migration", + "description": "Implement a new video distribution platform in Microsoft Azure", + "clientName": "Southridge Video", + "clientContact": "Tomo Takahashi", + "clientEmail": "tomot@southridgevideo.com", + "location": { + "street": "1 Martin Place, Level 15", + "city": "Sydney", + "state": "New South Wales", + "country": "Australia", + "postalCode": "NSW 2000", + "latitude": -33.8679, + "longitude": 151.2073 + } + }, + { + "id": "10", + "name": "Financial data plugin for Microsoft Copilot", + "description": "Extend Woodgrove's financial analytics platform to integrate with Microsoft Copilot", + "clientName": "Woodgrove Bank", + "clientContact": "Bi Gao", + "clientEmail": "bigao@woodgrovebank.com", + "location": { + "street": "1 Microsoft Way", + "city": "Redmond", + "state": "WA", + "country": "USA", + "postalCode": "98052", + "latitude": 47.6396, + "longitude": -122.1295 + } + } + ] +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/env/.env.local b/samples/cext-trey-research-python/env/.env.local new file mode 100644 index 00000000..e5f038c2 --- /dev/null +++ b/samples/cext-trey-research-python/env/.env.local @@ -0,0 +1,17 @@ +# This file includes environment variables that can be committed to git. It's gitignored by default because it represents your local development environment. + +# Built-in environment variables +TEAMSFX_ENV=local + +# Generated during provision, you can also add your own variables. If you're adding a secret value, add SECRET_ prefix to the name so Teams Toolkit can handle them properly +OPENAPI_SERVER_URL= +BOT_DOMAIN= +TEAMS_APP_ID= +TEAMS_APP_TENANT_ID= +RESOURCE_SUFFIX= +AZURE_SUBSCRIPTION_ID= +AZURE_RESOURCE_GROUP_NAME= +APP_NAME_SUFFIX= +M365_TITLE_ID= +M365_APP_ID= +TENANT_NAME= \ No newline at end of file diff --git a/samples/cext-trey-research-python/env/.env.local.user b/samples/cext-trey-research-python/env/.env.local.user new file mode 100644 index 00000000..59bbe874 --- /dev/null +++ b/samples/cext-trey-research-python/env/.env.local.user @@ -0,0 +1 @@ +SECRET_STORAGE_ACCOUNT_CONNECTION_STRING= \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/__init__.py b/samples/cext-trey-research-python/functions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/cext-trey-research-python/functions/consultants/__init__.py b/samples/cext-trey-research-python/functions/consultants/__init__.py new file mode 100644 index 00000000..caf6a12b --- /dev/null +++ b/samples/cext-trey-research-python/functions/consultants/__init__.py @@ -0,0 +1,10 @@ +import sys +import os +import azure.functions as func +# Add the parent directory to the system path to access the `services` module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) + +from .consultants import consultants + +async def main(req: func.HttpRequest) -> func.HttpResponse: + return await consultants(req) diff --git a/samples/cext-trey-research-python/functions/consultants/consultants.py b/samples/cext-trey-research-python/functions/consultants/consultants.py new file mode 100644 index 00000000..ec1becda --- /dev/null +++ b/samples/cext-trey-research-python/functions/consultants/consultants.py @@ -0,0 +1,76 @@ +import azure.functions as func +import logging +from services.consultant_api_service import consultant_api_service +from services.identity_service import identity_service +from services.utilities import clean_up_parameter +import json +import dataclasses + +async def consultants(req: func.HttpRequest) -> func.HttpResponse: + logging.info("HTTP trigger function consultants processed a request.") + + # Initialize the response JSON + response_json = { + "results": [] + } + + try: + # Validate the request using IdentityService + await identity_service.validate_request(req) + # Get the input parameters from the request + consultant_name = req.params.get('consultantName', '').lower() + project_name = req.params.get('projectName', '').lower() + skill = req.params.get('skill', '').lower() + certification = req.params.get('certification', '').lower() + role = req.params.get('role', '').lower() + hours_available = req.params.get('hoursAvailable', '').lower() + logging.info("Checking consultant_id from route parameters.") + consultant_id = req.route_params.get('id') + + if consultant_id: + consultant_id = consultant_id.lower() + logging.info(f"➡️ GET /api/consultants/{consultant_id}: request for consultant {consultant_id}") + result = await consultant_api_service.get_api_consultant_by_id(consultant_id) + response_json['results'] = [vars(result)] + logging.info(f" ✅ GET /api/consultants/{consultant_id}: response status 1 consultant returned") + return func.HttpResponse( + body=json.dumps(response_json), + mimetype="application/json", + status_code=200 + ) + + logging.info(f"➡️ GET /api/consultants: request for consultantName={consultant_name}, " + f"projectName={project_name}, skill={skill}, certification={certification}, " + f"role={role}, hoursAvailable={hours_available}") + + # Clean up the parameters + consultant_name = clean_up_parameter("consultantName", consultant_name) + project_name = clean_up_parameter("projectName", project_name) + skill = clean_up_parameter("skill", skill) + certification = clean_up_parameter("certification", certification) + role = clean_up_parameter("role", role) + hours_available = clean_up_parameter("hoursAvailable", hours_available) + + # Get consultants based on the filters + result = await consultant_api_service.get_api_consultants( + consultant_name, project_name, skill, certification, role, hours_available + ) + response_json['results'] = [dataclasses.asdict(ApiConsultant) for ApiConsultant in result] + logging.info(f" ✅ GET /api/consultants: response status 200; {len(result)} consultants returned") + return func.HttpResponse( + body=json.dumps(response_json), + mimetype="application/json", + status_code=200 + ) + + except Exception as e: + logging.error(f" ⛔ Returning error status code 500: {str(e)}") + response_json['results'] = { + "status": 500, + "message": str(e) + } + return func.HttpResponse( + body=json.dumps(response_json), + mimetype="application/json", + status_code=500 + ) \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/consultants/function.json b/samples/cext-trey-research-python/functions/consultants/function.json new file mode 100644 index 00000000..45a2d4f7 --- /dev/null +++ b/samples/cext-trey-research-python/functions/consultants/function.json @@ -0,0 +1,17 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": ["get"], + "route": "api/consultants/{id?}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] + } \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/host.json b/samples/cext-trey-research-python/functions/host.json new file mode 100644 index 00000000..9498431c --- /dev/null +++ b/samples/cext-trey-research-python/functions/host.json @@ -0,0 +1,9 @@ +{ + "version": "2.0", + "extensions": { + "http": { + "routePrefix": "" + } + } + } + \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/local.settings.json b/samples/cext-trey-research-python/functions/local.settings.json new file mode 100644 index 00000000..db5899e4 --- /dev/null +++ b/samples/cext-trey-research-python/functions/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python" + } + } \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/me/__init__.py b/samples/cext-trey-research-python/functions/me/__init__.py new file mode 100644 index 00000000..a3a09f89 --- /dev/null +++ b/samples/cext-trey-research-python/functions/me/__init__.py @@ -0,0 +1,10 @@ +import sys +import os +import azure.functions as func +# Add the parent directory to the system path to access the `services` module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) + +from .me import me + +async def main(req: func.HttpRequest) -> func.HttpResponse: + return await me(req) \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/me/function.json b/samples/cext-trey-research-python/functions/me/function.json new file mode 100644 index 00000000..82e00371 --- /dev/null +++ b/samples/cext-trey-research-python/functions/me/function.json @@ -0,0 +1,17 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": ["get", "post"], + "route": "api/me/{command?}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] + } \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/me/me.py b/samples/cext-trey-research-python/functions/me/me.py new file mode 100644 index 00000000..041b9a00 --- /dev/null +++ b/samples/cext-trey-research-python/functions/me/me.py @@ -0,0 +1,94 @@ +import azure.functions as func +import logging +import json +import re +from services.identity_service import identity_service +from services.consultant_api_service import consultant_api_service +from services.utilities import HttpError, clean_up_parameter +import dataclasses + +async def me(req: func.HttpRequest) -> func.HttpResponse: + logging.info("HTTP trigger function 'me' processed a request.") + + res = { + "status": 200, + "results": [] + } + + try: + me = await identity_service.validate_request(req) + command = req.route_params.get("command", "").lower() + body = None + + if req.method == "GET": + if command: + raise HttpError(400, f"Invalid command: {command}") + + logging.info("➡️ GET /api/me request") + res["results"] = [dataclasses.asdict(me)] + logging.info(f"✅ GET /me response status {res['status']}; {len(res['results'])} consultants returned") + return func.HttpResponse( + json.dumps(res), + status_code=res["status"], + mimetype="application/json" + ) + + elif req.method == "POST": + if command == "chargetime": + try: + raw_body = req.get_body().decode('utf-8') + formatted_body = re.sub(r'([a-zA-Z0-9_]+):', r'"\1":', raw_body) + body = json.loads(formatted_body) + except ValueError as e: + logging.error(f"Failed to parse JSON body: {e}") + raise HttpError(400, "No body to process this request.") + + if body: + project_name = clean_up_parameter("projectName", body.get("projectName")) + if not project_name: + raise HttpError(400, "Missing project name") + + hours = body.get("hours") + if not hours: + raise HttpError(400, "Missing hours") + if not isinstance(hours, (int, float)) or hours < 0 or hours > 24: + raise HttpError(400, f"Invalid hours: {hours}") + + logging.info(f"➡️ POST /api/me/chargetime request for project {project_name}, hours {hours}") + result = await consultant_api_service.charge_time_to_project(project_name, me.id, hours) + + res["results"] = { + "status": 200, + "clientName": result.clientName, + "projectName": result.projectName, + "remainingForecast": result.remainingForecast, + "message": result.message + } + logging.info(f"✅ POST /api/me/chargetime response status {res['status']}; {result.message}") + + else: + raise HttpError(400, "Missing request body") + return func.HttpResponse( + json.dumps(res), + status_code=res["status"], + mimetype="application/json" + ) + else: + raise HttpError(400, f"Invalid command: {command}") + + else: + raise HttpError(405, f"Method not allowed: {req.method}") + + except HttpError as error: + status = error.status or 500 + logging.error(f"⛔ Returning error status code {status}: {str(error)}") + res["status"] = status + res["results"] = { + "status": status, + "message": str(error) + } + return func.HttpResponse( + json.dumps(res), + status_code=status, + mimetype="application/json" + ) \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/projects/__init__.py b/samples/cext-trey-research-python/functions/projects/__init__.py new file mode 100644 index 00000000..e8031ab1 --- /dev/null +++ b/samples/cext-trey-research-python/functions/projects/__init__.py @@ -0,0 +1,11 @@ +import sys +import os +import azure.functions as func +# Add the parent directory to the system path to access the `services` module +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))) +from me import me + +from .projects import projects + +async def main(req: func.HttpRequest) -> func.HttpResponse: + return await projects(req) \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/projects/function.json b/samples/cext-trey-research-python/functions/projects/function.json new file mode 100644 index 00000000..341f57fe --- /dev/null +++ b/samples/cext-trey-research-python/functions/projects/function.json @@ -0,0 +1,17 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": ["get", "post"], + "route": "api/projects/{id?}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] + } \ No newline at end of file diff --git a/samples/cext-trey-research-python/functions/projects/projects.py b/samples/cext-trey-research-python/functions/projects/projects.py new file mode 100644 index 00000000..42107392 --- /dev/null +++ b/samples/cext-trey-research-python/functions/projects/projects.py @@ -0,0 +1,132 @@ +import azure.functions as func +import logging +import json +import re +from services.project_api_service import project_api_service +from services.identity_service import identity_service +from services.utilities import HttpError, clean_up_parameter + +async def projects(req: func.HttpRequest) -> func.HttpResponse: + logging.info("HTTP trigger function 'projects' processed a request.") + + res = { + "status": 200, + "results": [] + } + + try: + # Will throw an exception if the request is not valid + user_info = await identity_service.validate_request(req) + + # Extract the 'id' parameter from the route + project_id = req.route_params.get('id', '').lower() + + if req.method == 'GET': + project_name = req.params.get('projectName', '').lower() + consultant_name = req.params.get('consultantName', '').lower() + + logging.info(f"➡️ GET /api/projects: request for projectName={project_name}, consultantName={consultant_name}, id={project_id}") + + project_name = clean_up_parameter('projectName', project_name) + consultant_name = clean_up_parameter('consultantName', consultant_name) + + if project_id: + result = await project_api_service.get_api_project_by_id(project_id) + + # Convert the ApiProject object to a dictionary + res['results'] = [result.to_dict()] if hasattr(result, 'to_dict') else [vars(result)] + + logging.info(f"✅ GET /api/projects: response status {res['status']}; 1 project returned") + return func.HttpResponse( + json.dumps(res), + status_code=res['status'], + mimetype="application/json" + ) + + # Fetch projects for the current user if projectName includes 'user_profile' + if 'user_profile' in project_name: + result = await project_api_service.get_api_projects("", user_info['name']) + res['results'] = [result.to_dict()] if hasattr(result, 'to_dict') else [vars(result)] + logging.info(f"✅ GET /api/projects for current user response status {res['status']}; {len(result)} projects returned") + return func.HttpResponse( + json.dumps(res), + status_code=res['status'], + mimetype="application/json" + ) + + # Fetch by projectName and consultantName + result = await project_api_service.get_api_projects(project_name, consultant_name) + + # Convert the list of ApiProject objects to dictionaries + res['results'] = [r.to_dict() if hasattr(r, 'to_dict') else vars(r) for r in result] + + logging.info(f"✅ GET /api/projects: response status {res['status']}; {len(result)} projects returned") + return func.HttpResponse( + json.dumps(res), + status_code=res['status'], + mimetype="application/json" + ) + + elif req.method == 'POST': + if project_id == 'assignconsultant': + try: + raw_body = req.get_body().decode('utf-8') + formatted_body = re.sub(r'([a-zA-Z0-9_]+):', r'"\1":', raw_body) + body = json.loads(formatted_body) + except ValueError: + raise HttpError(400, "No body to process this request.") + + if body: + project_name = clean_up_parameter('projectName', body.get('projectName')) + if not project_name: + raise HttpError(400, "Missing project name") + + consultant_name = clean_up_parameter('consultantName', body.get('consultantName', '').lower()) + if not consultant_name: + raise HttpError(400, "Missing consultant name") + + role = clean_up_parameter('Role', body.get('role')) + if not role: + raise HttpError(400, "Missing role") + + forecast = body.get('forecast', 0) + + logging.info(f"➡️ POST /api/projects: assignconsultant request, projectName={project_name}, consultantName={consultant_name}, role={role}, forecast={forecast}") + result = await project_api_service.add_consultant_to_project(project_name, consultant_name, role, forecast) + + res['results'] = { + 'status': 200, + 'clientName': result.clientName, + 'projectName': result.projectName, + 'consultantName': result.consultantName, + 'remainingForecast': result.remainingForecast, + 'message': result.message + } + + logging.info(f"✅ POST /api/projects: response status {res['status']} - {result.message}") + return func.HttpResponse( + json.dumps(res), + status_code=res['status'], + mimetype="application/json" + ) + else: + raise HttpError(400, "Missing request body") + else: + raise HttpError(400, f"Invalid command: {project_id}") + + else: + raise HttpError(405, f"Method not allowed: {req.method}") + + except HttpError as error: + status = error.status or 500 + logging.error(f"⛔ Returning error status code {status}: {str(error)}") + res['status'] = status + res['results'] = { + 'status': status, + 'message': str(error) + } + return func.HttpResponse( + json.dumps(res), + status_code=status, + mimetype="application/json" + ) \ No newline at end of file diff --git a/samples/cext-trey-research-python/http/treyResearchAPI.http b/samples/cext-trey-research-python/http/treyResearchAPI.http new file mode 100644 index 00000000..96753c25 --- /dev/null +++ b/samples/cext-trey-research-python/http/treyResearchAPI.http @@ -0,0 +1,69 @@ +@base_url = http://localhost:7071/api + +########## /api/me - working with the Copilot user ########## + +### Get my consultant and project information +{{base_url}}/me + +### Charge time to a project +POST {{base_url}}/me/chargeTime +Content-Type: application/json + +{ + "projectName": "woodgrove", + "hours": 3 +} + + +########## /api/consultants - working with consultants ########## + +### Get all consultants +{{base_url}}/consultants + +### Get consultant by id +{{base_url}}/consultants/1 + +### Get consulatnt by name +{{base_url}}/consultants/?consultantName=Sanjay + +### Get consultants by project +{{base_url}}/consultants/?projectName=woodgrove + +### Get consultants by skill +{{base_url}}/consultants/?skill=javascript + +### Get consultants by certification +{{base_url}}/consultants/?certification=azure + +### Get consultants by role +{{base_url}}/consultants/?role=developer + +### Get consultants by hours available this month +{{base_url}}/consultants/?hoursAvailable=100 + + +########## /api/projects - working with projects ########## + +### Get all projects +{{base_url}}/projects + +### Get project by id +{{base_url}}/projects/1 + +### Get project by project or client name +{{base_url}}/projects/?projectName=supply + +### Get project by consultant name +{{base_url}}/projects/?consultantName=dominique + +### Add consultant to project +POST {{base_url}}/projects/assignConsultant +Content-Type: application/json + +{ + "projectName": "contoso", + "consultantName": "sanjay", + "role": "architect", + "forecast": 30 +} + diff --git a/samples/cext-trey-research-python/infra/azure.bicep b/samples/cext-trey-research-python/infra/azure.bicep new file mode 100644 index 00000000..8734cb54 --- /dev/null +++ b/samples/cext-trey-research-python/infra/azure.bicep @@ -0,0 +1,42 @@ +@maxLength(20) +@minLength(4) +@description('Used to generate names for all resources in this file') +param resourceBaseName string + +@description('Required when create Azure Bot service') +param botAadAppClientId string + +param botAppDomain string + +@maxLength(42) +param botDisplayName string + +param botServiceName string = resourceBaseName +param botServiceSku string = 'F0' + +// Register your web service as a bot with the Bot Framework +resource botService 'Microsoft.BotService/botServices@2021-03-01' = { + kind: 'azurebot' + location: 'global' + name: botServiceName + properties: { + displayName: botDisplayName + endpoint: 'https://${botAppDomain}/api/messages' + msaAppId: botAadAppClientId + msaAppType: 'MultiTenant' + msaAppTenantId: '' + } + sku: { + name: botServiceSku + } +} + +// Connect the bot service to Microsoft Teams +resource botServiceMsTeamsChannel 'Microsoft.BotService/botServices/channels@2021-03-01' = { + parent: botService + location: 'global' + name: 'MsTeamsChannel' + properties: { + channelName: 'MsTeamsChannel' + } +} diff --git a/samples/cext-trey-research-python/infra/azure.parameters.json b/samples/cext-trey-research-python/infra/azure.parameters.json new file mode 100644 index 00000000..3d95f6a3 --- /dev/null +++ b/samples/cext-trey-research-python/infra/azure.parameters.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "resourceBaseName": { + "value": "bot${{RESOURCE_SUFFIX}}" + }, + "botAppDomain": { + "value": "${{BOT_DOMAIN}}" + }, + "botDisplayName": { + "value": "CextTreyResearch" + } + } + } \ No newline at end of file diff --git a/samples/cext-trey-research-python/model/__init__.py b/samples/cext-trey-research-python/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/cext-trey-research-python/model/api_model.py b/samples/cext-trey-research-python/model/api_model.py new file mode 100644 index 00000000..dc952b3d --- /dev/null +++ b/samples/cext-trey-research-python/model/api_model.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass, field +from typing import List +from model.base_model import Project, Consultant, Location + +# GET requests for /projects + +@dataclass +class ApiProjectAssignment: + consultantName: str + consultantLocation: Location + role: str + forecastThisMonth: float + forecastNextMonth: float + deliveredLastMonth: float + deliveredThisMonth: float + + +@dataclass +class ApiProject(Project): + consultants: List[ApiProjectAssignment] = field(default_factory=list) + forecastThisMonth: float = 0.0 + forecastNextMonth: float = 0.0 + deliveredLastMonth: float = 0.0 + deliveredThisMonth: float = 0.0 + + +# GET requests for /me and /consultants + +@dataclass +class ApiConsultantAssignment: + projectName: str + projectDescription: str + projectLocation: Location + clientName: str + clientContact: str + clientEmail: str + role: str + forecastThisMonth: float + forecastNextMonth: float + deliveredLastMonth: float + deliveredThisMonth: float + + +@dataclass +class ApiConsultant(Consultant): + projects: List[ApiConsultantAssignment] = field(default_factory=list) + forecastThisMonth: float = 0.0 + forecastNextMonth: float = 0.0 + deliveredLastMonth: float = 0.0 + deliveredThisMonth: float = 0.0 + + +# POST request to /api/me/chargeTime + +@dataclass +class ApiChargeTimeRequest: + projectName: str + hours: float + + +@dataclass +class ApiChargeTimeResponse: + clientName: str + projectName: str + remainingForecast: float + message: str + + +# POST request to /api/projects/assignConsultant + +@dataclass +class ApiAddConsultantToProjectRequest: + projectName: str + consultantName: str + role: str + hours: float + + +@dataclass +class ApiAddConsultantToProjectResponse: + clientName: str + projectName: str + consultantName: str + remainingForecast: float + message: str + + +@dataclass +class ErrorResult: + status: int + message: str diff --git a/samples/cext-trey-research-python/model/base_model.py b/samples/cext-trey-research-python/model/base_model.py new file mode 100644 index 00000000..b3f50775 --- /dev/null +++ b/samples/cext-trey-research-python/model/base_model.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass, field +from typing import List + +@dataclass +class Location: + street: str + city: str + state: str + country: str + postalCode: str + latitude: float + longitude: float + + +@dataclass +class HoursEntry: + month: int + year: int + hours: float + + +@dataclass +class Project: + id: str + name: str + description: str + clientName: str + clientContact: str + clientEmail: str + location: Location + mapUrl: str + + +@dataclass +class Consultant: + id: str + name: str + email: str + phone: str + consultantPhotoUrl: str + location: Location + skills: List[str] + certifications: List[str] + roles: List[str] + + +@dataclass +class Assignment: + id: str # The assignment ID is "projectid,consultantid" + projectId: str + consultantId: str + role: str + billable: bool + rate: float + forecast: List[HoursEntry] = field(default_factory=list) + delivered: List[HoursEntry] = field(default_factory=list) diff --git a/samples/cext-trey-research-python/model/db_model.py b/samples/cext-trey-research-python/model/db_model.py new file mode 100644 index 00000000..297cea4c --- /dev/null +++ b/samples/cext-trey-research-python/model/db_model.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field +from datetime import datetime +from model.base_model import Project, Consultant, Assignment + +@dataclass +class DbEntity: + partition_key: str = field(default="") + row_key: str = field(default="") + etag: str = field(default="") + timestamp: datetime = field(default_factory=datetime.utcnow) + +@dataclass +class DbProject(DbEntity, Project): + pass + +@dataclass +class DbConsultant(DbEntity, Consultant): + pass + +@dataclass +class DbAssignment(DbEntity, Assignment): + pass \ No newline at end of file diff --git a/samples/cext-trey-research-python/package.json b/samples/cext-trey-research-python/package.json new file mode 100644 index 00000000..5f0774cc --- /dev/null +++ b/samples/cext-trey-research-python/package.json @@ -0,0 +1,12 @@ +{ + "name": "officedev-copilot-for-m365-plugins-samples-cext-trey-research-python", + "version": "1.0.0", + "description": "This sample implements a Teams message extension that can be used as a plugin for Microsoft Copilot for Microsoft 365.", + "scripts": { + "storage": "azurite --silent --location ./_storage_emulator --debug ./_storage_emulator/debug.log", + "postinstall": "echo 'Please ensure Azure Functions Core Tools are installed globally by running npm install -g azure-functions-core-tools@4'" + }, + "devDependencies": { + "azurite": "^3.23.0" + } +} \ No newline at end of file diff --git a/samples/cext-trey-research-python/requirements.txt b/samples/cext-trey-research-python/requirements.txt new file mode 100644 index 00000000..61b8dbdd --- /dev/null +++ b/samples/cext-trey-research-python/requirements.txt @@ -0,0 +1,6 @@ +aiohttp +azure-data-tables +uuid +azure-functions +debugpy +requests==2.31.0 \ No newline at end of file diff --git a/samples/cext-trey-research-python/sampleDocs/Bellows College MSA.docx b/samples/cext-trey-research-python/sampleDocs/Bellows College MSA.docx new file mode 100644 index 00000000..87b55d38 Binary files /dev/null and b/samples/cext-trey-research-python/sampleDocs/Bellows College MSA.docx differ diff --git a/samples/cext-trey-research-python/sampleDocs/Bellows College NDA.docx b/samples/cext-trey-research-python/sampleDocs/Bellows College NDA.docx new file mode 100644 index 00000000..49d5cef9 Binary files /dev/null and b/samples/cext-trey-research-python/sampleDocs/Bellows College NDA.docx differ diff --git a/samples/cext-trey-research-python/sampleDocs/Bellows College SOW - Network security review.docx b/samples/cext-trey-research-python/sampleDocs/Bellows College SOW - Network security review.docx new file mode 100644 index 00000000..686e762a Binary files /dev/null and b/samples/cext-trey-research-python/sampleDocs/Bellows College SOW - Network security review.docx differ diff --git a/samples/cext-trey-research-python/sampleDocs/My Hours.xlsx b/samples/cext-trey-research-python/sampleDocs/My Hours.xlsx new file mode 100644 index 00000000..7d55a04d Binary files /dev/null and b/samples/cext-trey-research-python/sampleDocs/My Hours.xlsx differ diff --git a/samples/cext-trey-research-python/sampleDocs/Woodgrove MSA.docx b/samples/cext-trey-research-python/sampleDocs/Woodgrove MSA.docx new file mode 100644 index 00000000..cf6e9694 Binary files /dev/null and b/samples/cext-trey-research-python/sampleDocs/Woodgrove MSA.docx differ diff --git a/samples/cext-trey-research-python/sampleDocs/Woodgrove NDA.docx b/samples/cext-trey-research-python/sampleDocs/Woodgrove NDA.docx new file mode 100644 index 00000000..ddecfcd0 Binary files /dev/null and b/samples/cext-trey-research-python/sampleDocs/Woodgrove NDA.docx differ diff --git a/samples/cext-trey-research-python/sampleDocs/Woodgrove SOW - Financial data plugin for Microsoft Copilot.docx b/samples/cext-trey-research-python/sampleDocs/Woodgrove SOW - Financial data plugin for Microsoft Copilot.docx new file mode 100644 index 00000000..04636b5e Binary files /dev/null and b/samples/cext-trey-research-python/sampleDocs/Woodgrove SOW - Financial data plugin for Microsoft Copilot.docx differ diff --git a/samples/cext-trey-research-python/services/__init__.py b/samples/cext-trey-research-python/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/cext-trey-research-python/services/assignment_db_service.py b/samples/cext-trey-research-python/services/assignment_db_service.py new file mode 100644 index 00000000..46880fe6 --- /dev/null +++ b/samples/cext-trey-research-python/services/assignment_db_service.py @@ -0,0 +1,110 @@ +from .db_service import DbService +from model.db_model import DbAssignment +from model.base_model import Assignment +from .utilities import HttpError +from datetime import datetime +import logging +TABLE_NAME = "Assignment" + +class AssignmentDbService: + + # NOTE: Assignments are READ-WRITE so disable local caching + def __init__(self): + self.db_service = DbService[DbAssignment](ok_to_cache_locally=False) + + async def get_assignments(self) -> list[Assignment]: + assignments = await self.db_service.get_entities(TABLE_NAME) + result = [self.convert_db_assignment(e) for e in assignments] + return result + + async def charge_hours_to_project(self, project_id: str, consultant_id: str, month: int, year: int, hours: int) -> int: + try: + logging.info(f"tablename : {TABLE_NAME},project id : {project_id}, consultant_id :{consultant_id} ") + db_assignment = await self.db_service.get_entity_by_row_key(TABLE_NAME, f"{project_id},{consultant_id}") + if not db_assignment: + raise HttpError(404, "Assignment not found") + + # Add the hours delivered + if not db_assignment.get("delivered"): + db_assignment["delivered"] = [{"month": month, "year": year, "hours": hours}] + else: + assignment_record = next((d for d in db_assignment["delivered"] if d["month"] == month and d["year"] == year), None) + if assignment_record: + assignment_record["hours"] += hours + else: + db_assignment["delivered"].append({"month": month, "year": year, "hours": hours}) + + db_assignment["delivered"].sort(key=lambda d: (d["year"], d["month"])) + + # Subtract the hours from the forecast + remaining_forecast = -hours + if not db_assignment.get("forecast"): + db_assignment["forecast"] = [{"month": month, "year": year, "hours": -hours}] + else: + forecast_record = next((d for d in db_assignment["forecast"] if d["month"] == month and d["year"] == year), None) + if forecast_record: + forecast_record["hours"] -= hours + remaining_forecast = forecast_record["hours"] + else: + db_assignment["forecast"].append({"month": month, "year": year, "hours": -hours}) + + db_assignment["forecast"].sort(key=lambda d: (d["year"], d["month"])) + + await self.db_service.update_entity(TABLE_NAME, db_assignment) + return remaining_forecast + + except Exception as e: + raise HttpError(404, f"Assignment not found : {e}") + + + async def add_consultant_to_project(self, project_id: str, consultant_id: str, role: str, hours: int) -> int: + month = datetime.utcnow().month + year = datetime.utcnow().year + + db_assignment = None + try: + db_assignment = await self.db_service.get_entity_by_row_key(TABLE_NAME, f"{project_id},{consultant_id}") + except Exception: + pass + + if db_assignment: + raise HttpError(403, "Assignment already exists") + + try: + new_assignment = DbAssignment( + partition_key=TABLE_NAME, + row_key=f"{project_id},{consultant_id}", + timestamp=datetime.utcnow(), + id=f"{project_id},{consultant_id}", + projectId=project_id, + consultantId=consultant_id, + role=role, + billable=True, + rate=100, + forecast=[{"month": month, "year": year, "hours": hours}], + delivered=[], + etag="" + ) + + await self.db_service.create_entity(TABLE_NAME, new_assignment.row_key, new_assignment) + return hours + + except Exception as e: + raise HttpError(500, f"Unable to add assignment :{e}") + + + def convert_db_assignment(self, db_assignment: DbAssignment) -> Assignment: + result = Assignment( + id=db_assignment.get('id'), + projectId=db_assignment.get('projectId'), + consultantId=db_assignment.get('consultantId'), + role=db_assignment.get('role'), + billable=db_assignment.get('billable'), + rate=db_assignment.get('rate'), + forecast=db_assignment.get('forecast'), + delivered=db_assignment.get('delivered') + ) + return result + +# Instantiate and use the AssignmentDbService +assignment_db_service = AssignmentDbService() \ No newline at end of file diff --git a/samples/cext-trey-research-python/services/consultant_api_service.py b/samples/cext-trey-research-python/services/consultant_api_service.py new file mode 100644 index 00000000..57f03d08 --- /dev/null +++ b/samples/cext-trey-research-python/services/consultant_api_service.py @@ -0,0 +1,153 @@ +from typing import List, Optional +from datetime import datetime +from model.base_model import Consultant, HoursEntry, Assignment +from model.api_model import ApiConsultant, ApiChargeTimeResponse +from services.project_db_service import project_db_service +from services.assignment_db_service import assignment_db_service +from services.consultant_db_service import consultant_db_service +from .utilities import HttpError +import asyncio + +AVAILABLE_HOURS_PER_MONTH = 160 + +class ConsultantApiService: + + async def get_api_consultant_by_id(self, consultant_id: str) -> Optional[ApiConsultant]: + result = None + consultant = await consultant_db_service.get_consultant_by_id(consultant_id) + if consultant: + assignments = await assignment_db_service.get_assignments() + result = await self.get_api_consultant_for_base_consultant(consultant, assignments) + return result + + async def get_api_consultants(self, consultant_name: str, project_name: str, skill: str, + certification: str, role: str, hours_available: str) -> List[ApiConsultant]: + + consultants = await consultant_db_service.get_consultants() + assignments = await assignment_db_service.get_assignments() + + # Filter on base properties + if consultant_name: + consultants = [c for c in consultants if consultant_name.lower() in c.get("name").lower()] + if skill: + consultants = [c for c in consultants if any(skill.lower() in s.lower() for s in c.get("skills"))] + if certification: + consultants = [c for c in consultants if any(certification.lower() in cert.lower() for cert in c.get("certifications"))] + if role: + consultants = [c for c in consultants if role.lower() in [r.lower() for r in c.get("roles")]] + + # Augment the base properties with assignment information + result = await asyncio.gather(*[self.get_api_consultant_for_base_consultant(c, assignments) for c in consultants]) + + # Filter on project name + if project_name: + result = [c for c in result if any( + project_name.lower() in (p.get("projectName").lower() + p.get("clientName").lower()) for p in c.projects + )] + + # Filter on available hours + if hours_available: + hours_available = int(hours_available) + result = [c for c in result if AVAILABLE_HOURS_PER_MONTH * 2 - c.forecast_this_month - c.forecast_next_month >= hours_available] + + return result + + async def create_api_consultant(self, consultant: Consultant) -> ApiConsultant: + await consultant_db_service.create_consultant(consultant) + assignments = await assignment_db_service.get_assignments() + + new_api_consultant = await self.get_api_consultant_for_base_consultant(consultant, assignments) + return new_api_consultant + + async def get_api_consultant_for_base_consultant(self, consultant: Consultant, assignments: List[Assignment]) -> ApiConsultant: + result = ApiConsultant( + id=consultant.get('id'), + name=consultant.get('name'), + email=consultant.get('email'), + phone=consultant.get('phone'), + consultantPhotoUrl=consultant.get('consultantPhotoUrl'), + location=consultant.get('location'), + skills=consultant.get('skills'), + certifications=consultant.get('certifications'), + roles=consultant.get('roles'), + projects=[], + forecastThisMonth=0, + forecastNextMonth= 0, + deliveredLastMonth= 0, + deliveredThisMonth= 0 + ) + + assignments = [a for a in assignments if a.consultantId == consultant.get('id')] + + for assignment in assignments: + project = await project_db_service.get_project_by_id(assignment.projectId) + forecast_hours = self.find_hours(assignment.forecast) + delivered_hours = self.find_hours(assignment.delivered) + result.projects.append({ + "projectName": project.name, + "projectDescription": project.description, + "projectLocation": project.location, + "mapUrl": project.mapUrl, + "clientName": project.clientName, + "clientContact": project.clientContact, + "clientEmail": project.clientEmail, + "role": assignment.role, + "forecastThisMonth": forecast_hours["thisMonthHours"], + "forecastNextMonth": forecast_hours["nextMonthHours"], + "deliveredLastMonth": delivered_hours["lastMonthHours"], + "deliveredThisMonth": delivered_hours["thisMonthHours"] + }) + + result.forecastThisMonth += forecast_hours["thisMonthHours"] + result.forecastNextMonth += forecast_hours["nextMonthHours"] + result.deliveredLastMonth += delivered_hours["lastMonthHours"] + result.deliveredThisMonth += delivered_hours["thisMonthHours"] + + return result + + def find_hours(self, hours: List[HoursEntry]) -> dict: + now = datetime.now() + this_month = now.month + this_year = now.year + + last_month = 12 if this_month == 1 else this_month - 1 + last_year = this_year - 1 if this_month == 1 else this_year + + next_month = 1 if this_month == 12 else this_month + 1 + next_year = this_year + 1 if this_month == 12 else this_year + return { + "lastMonthHours": next((h.get('hours') for h in hours if h.get('month') == last_month and h.get('year') == last_year), 0), + "thisMonthHours": next((h.get('hours') for h in hours if h.get('month') == this_month and h.get('year') == this_year), 0), + "nextMonthHours": next((h.get('hours') for h in hours if h.get('month') == next_month and h.get('year') == next_year), 0) + } + + async def charge_time_to_project(self, project_name: str, consultant_id: str, hours: int) -> ApiChargeTimeResponse: + from services.project_api_service import project_api_service + projects = await project_api_service.get_api_projects(project_name, "") + if not projects: + raise HttpError(404, f"Project not found: {project_name}") + if len(projects) > 1: + raise HttpError(406, f"Multiple projects found with the name: {project_name}") + + project = projects[0] + + # Always charge to the current month + month = datetime.now().month + year = datetime.now().year + + remaining_forecast = await assignment_db_service.charge_hours_to_project(project.id, consultant_id, month, year, hours) + + if remaining_forecast < 0: + message = f"Charged {hours} hours to {project.clientName} on project \"{project.name}\". You are {-remaining_forecast} hours over your forecast this month." + else: + message = f"Charged {hours} hours to {project.clientName} on project \"{project.name}\". You have {remaining_forecast} hours remaining this month." + + return ApiChargeTimeResponse( + clientName=project.clientName, + projectName=project.name, + remainingForecast=remaining_forecast, + message=message + ) + +# Export instance of ConsultantApiService +consultant_api_service = ConsultantApiService() \ No newline at end of file diff --git a/samples/cext-trey-research-python/services/consultant_db_service.py b/samples/cext-trey-research-python/services/consultant_db_service.py new file mode 100644 index 00000000..db32c606 --- /dev/null +++ b/samples/cext-trey-research-python/services/consultant_db_service.py @@ -0,0 +1,34 @@ +from .db_service import DbService +from model.db_model import DbConsultant +from model.base_model import Consultant +from datetime import datetime + +TABLE_NAME = "Consultant" + +class ConsultantDbService: + # NOTE: Consultants are READ ONLY in this demo app, so we are free to cache them in memory. + def __init__(self): + self.db_service = DbService[DbConsultant](ok_to_cache_locally=True) + + async def get_consultant_by_id(self, consultant_id: str) -> Consultant: + consultant = await self.db_service.get_entity_by_row_key(TABLE_NAME, consultant_id) + return consultant + + async def get_consultants(self) -> list[Consultant]: + consultants = await self.db_service.get_entities(TABLE_NAME) + return consultants + + async def create_consultant(self, consultant: Consultant) -> Consultant: + new_db_consultant = DbConsultant( + **consultant.__dict__, + etag="", + partition_key=TABLE_NAME, + row_key=consultant.id, + timestamp=datetime.utcnow() + ) + await self.db_service.create_entity(TABLE_NAME, new_db_consultant.row_key, new_db_consultant) + print(f"Added new consultant {new_db_consultant.name} ({new_db_consultant.row_key}) to the Consultant table") + return None + +# Instantiate and use the ConsultantDbService +consultant_db_service = ConsultantDbService() \ No newline at end of file diff --git a/samples/cext-trey-research-python/services/db_service.py b/samples/cext-trey-research-python/services/db_service.py new file mode 100644 index 00000000..a97c06c0 --- /dev/null +++ b/samples/cext-trey-research-python/services/db_service.py @@ -0,0 +1,101 @@ +import os +import json +from azure.data.tables import TableClient, UpdateMode +from azure.core.exceptions import ResourceExistsError +from .utilities import HttpError +from typing import TypeVar, Generic, List, Any +import asyncio, logging, dataclasses + + +# Define a generic type for DbEntityType +DbEntityType = TypeVar('DbEntityType') + +class DbService(Generic[DbEntityType]): + + def __init__(self, ok_to_cache_locally: bool): + self.storage_account_connection_string = os.getenv('STORAGE_ACCOUNT_CONNECTION_STRING') + if not self.storage_account_connection_string: + raise ValueError("STORAGE_ACCOUNT_CONNECTION_STRING is not set") + + self.ok_to_cache_locally = ok_to_cache_locally + self.entity_cache: List[DbEntityType] = [] + + async def get_entity_by_row_key(self, table_name: str, row_key: str) -> DbEntityType: + if not self.ok_to_cache_locally: + entities = await self.get_entities(table_name) + filtered_entities = [e for e in entities if e['RowKey'] == row_key] + return self.expand_property_values(filtered_entities[0]) + + else: + entities = await self.get_entities(table_name) + filtered_entities = [e for e in entities if e['RowKey'] == row_key] + if not filtered_entities: + raise HttpError(404, f"Entity {row_key} not found") + return filtered_entities[0] + + async def get_entities(self, table_name: str) -> List[DbEntityType]: + if not self.ok_to_cache_locally or not self.entity_cache: + table_client = TableClient.from_connection_string(self.storage_account_connection_string, table_name) + entities = table_client.list_entities() + self.entity_cache = [] + for entity in entities: + if not any(e['RowKey'] == entity['RowKey'] for e in self.entity_cache): + expanded_entity = self.expand_property_values(entity) + self.entity_cache.append(expanded_entity) + return self.entity_cache + + async def create_entity(self, table_name: str, row_key: str, new_entity: DbEntityType) -> None: + self.entity_cache = [] + logging.info(f"line 49 {new_entity}") + new_entity_dict = dataclasses.asdict(new_entity) + if isinstance(new_entity_dict.get("forecast"), list): + new_entity_dict["forecast"] = json.dumps(new_entity_dict["forecast"]) + + if isinstance(new_entity_dict.get("delivered"), list): + new_entity_dict["delivered"] = json.dumps(new_entity_dict["delivered"]) + + table_client = TableClient.from_connection_string(self.storage_account_connection_string, table_name) + try: + table_client.create_entity({ + "PartitionKey": table_name, + "RowKey": row_key, + **new_entity_dict + }) + except ResourceExistsError: + raise HttpError(409, f"Entity with RowKey {row_key} already exists") + + async def update_entity(self, table_name: str, updated_entity: DbEntityType) -> None: + self.entity_cache = [] + compressed_entity = self.compress_property_values(updated_entity) + table_client = TableClient.from_connection_string(self.storage_account_connection_string, table_name) + + # Perform synchronous update call within an asynchronous function + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, table_client.update_entity, compressed_entity, UpdateMode.REPLACE) + + def expand_property_values(self, entity: Any) -> DbEntityType: + expanded_entity = {} + for key, value in entity.items(): + expanded_entity[key] = self.expand_property_value(value) + return expanded_entity + + def expand_property_value(self, value: Any) -> Any: + if isinstance(value, str) and (value.startswith('{') or value.startswith('[')): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + else: + return value + + def compress_property_values(self, entity: DbEntityType) -> DbEntityType: + compressed_entity = {} + for key, value in entity.items(): + compressed_entity[key] = self.compress_property_value(value) + return compressed_entity + + def compress_property_value(self, value: Any) -> Any: + if isinstance(value, (dict, list)): + return json.dumps(value) + else: + return value \ No newline at end of file diff --git a/samples/cext-trey-research-python/services/identity_service.py b/samples/cext-trey-research-python/services/identity_service.py new file mode 100644 index 00000000..1544ef43 --- /dev/null +++ b/samples/cext-trey-research-python/services/identity_service.py @@ -0,0 +1,62 @@ +from azure.functions import HttpRequest +from .utilities import HttpError +from model.base_model import Consultant +from model.api_model import ApiConsultant +from .consultant_api_service import consultant_api_service +import logging + +# This is a DEMO ONLY identity solution. +class IdentityService: + def __init__(self): + self.request_number = 1 # Number the requests for logging purposes + + async def validate_request(self, req: HttpRequest) -> ApiConsultant: + # Default user used for unauthenticated testing + user_id = "1" + user_name = "Avery Howard" + user_email = "avery@treyresearch.com" + + # ** INSERT REQUEST VALIDATION HERE (see Lab E6) ** + # You would add actual request validation logic here in a real-world scenario. + + # Get the consultant record for this user; create one if necessary + consultant = None + try: + consultant = await consultant_api_service.get_api_consultant_by_id(user_id) + except HttpError as ex: + if ex.status != 404: + raise + # Consultant was not found, so we'll create one below + consultant = None + + if not consultant: + consultant = await self.create_consultant_for_user(user_id, user_name, user_email) + + return consultant + + async def create_consultant_for_user(self, user_id: str, user_name: str, user_email: str) -> ApiConsultant: + # Create a new consultant record for this user with default values + consultant = Consultant( + id=user_id, + name=user_name, + email=user_email, + phone="1-555-123-4567", + consultant_photo_url="https://microsoft.github.io/copilot-camp/demo-assets/images/consultants/Unknown.jpg", + location={ + "street": "One Memorial Drive", + "city": "Cambridge", + "state": "MA", + "country": "USA", + "postal_code": "02142", + "latitude": 42.361366, + "longitude": -71.081257 + }, + skills=["JavaScript", "TypeScript"], + certifications=["Azure Development"], + roles=["Architect", "Project Lead"] + ) + result = await consultant_api_service.create_api_consultant(consultant) + return result + +# Instantiate and use the Identity service +identity_service = IdentityService() \ No newline at end of file diff --git a/samples/cext-trey-research-python/services/project_api_service.py b/samples/cext-trey-research-python/services/project_api_service.py new file mode 100644 index 00000000..07e39b67 --- /dev/null +++ b/samples/cext-trey-research-python/services/project_api_service.py @@ -0,0 +1,147 @@ +from model.base_model import Project, HoursEntry, Assignment +from model.api_model import ApiProject, ApiAddConsultantToProjectResponse +from services.project_db_service import project_db_service +from services.assignment_db_service import assignment_db_service +from services.consultant_db_service import consultant_db_service +from services.consultant_api_service import consultant_api_service +from .utilities import HttpError +from typing import List, Tuple +from datetime import datetime +import logging + +class ProjectApiService: + + async def get_api_project_by_id(self, project_id: str) -> ApiProject: + project = await project_db_service.get_project_by_id(project_id) + assignments = await assignment_db_service.get_assignments() + + result = await self.get_api_project(project, assignments) + return result + + async def get_api_projects(self, project_or_client_name: str, consultant_name: str) -> List[ApiProject]: + projects = await project_db_service.get_projects() + assignments = await assignment_db_service.get_assignments() + + # Filter on base properties + if project_or_client_name: + projects = [p for p in projects if ( + project_or_client_name.lower() in (p.name or '').lower() or + project_or_client_name.lower() in (p.clientName or '').lower() + )] + + result = [] + + # Remove duplicates + projects = list({p.id: p for p in projects}.values()) + + # Augment the base properties with assignment information + for project in projects: + api_project = await self.get_api_project(project, assignments) + result.append(api_project) + + # Filter on augmented properties + if result and consultant_name: + result = [ + p for p in result if any( + consultant_name.lower() in c['consultantName'].lower() for c in p.consultants + ) + ] + + return result + + async def get_api_project(self, project: Project, assignments: List[Assignment]) -> ApiProject: + result = ApiProject( + id=project.id, + name=project.name, + description=project.description, + location=project.location, + clientName=project.clientName, + clientContact=project.clientContact, + clientEmail=project.clientEmail, + mapUrl=project.mapUrl, + consultants=[], + forecastThisMonth=0, + forecastNextMonth=0, + deliveredLastMonth=0, + deliveredThisMonth=0 + ) + + # Filter the assignments related to this project + filtered_assignments = [a for a in assignments if a.projectId == project.id] + + # Populate consultants and forecast/delivery data + for assignment in filtered_assignments: + # Get the consultant associated with the assignment + consultant = await consultant_db_service.get_consultant_by_id(assignment.consultantId) + + # Calculate the forecast and delivered hours + forecast_last_month, forecast_this_month, forecast_next_month = self.find_hours(assignment.forecast) + delivered_last_month, delivered_this_month, _ = self.find_hours(assignment.delivered) + + # Append consultant details to the project + result.consultants.append({ + 'consultantName': consultant.get('name'), + 'consultantLocation': consultant.get('location'), + 'role': assignment.role, + 'forecastThisMonth': forecast_this_month, + 'forecastNextMonth': forecast_next_month, + 'deliveredLastMonth': delivered_last_month, + 'deliveredThisMonth': delivered_this_month, + }) + + # Update the overall project forecasts and deliveries + result.forecastThisMonth += forecast_this_month + result.forecastNextMonth += forecast_next_month + result.deliveredLastMonth += delivered_last_month + result.deliveredThisMonth += delivered_this_month + + return result + + def find_hours(self, hours: List[HoursEntry]) -> Tuple[int, int, int]: + now = datetime.now() + this_month = now.month + this_year = now.year + + last_month = 12 if this_month == 1 else this_month - 1 + last_year = this_year - 1 if this_month == 1 else this_year + + next_month = 1 if this_month == 12 else this_month + 1 + next_year = this_year + 1 if this_month == 12 else this_year + + last_month_hours = next((h.get('hours') for h in hours if h.get('month') == last_month and h.get('year') == last_year), 0) + this_month_hours = next((h.get('hours') for h in hours if h.get('month') == this_month and h.get('year') == this_year), 0) + next_month_hours = next((h.get('hours') for h in hours if h.get('month') == next_month and h.get('year') == next_year), 0) + + return last_month_hours, this_month_hours, next_month_hours + + async def add_consultant_to_project(self, project_name: str, consultant_name: str, role: str, hours: int) -> ApiAddConsultantToProjectResponse: + projects = await self.get_api_projects(project_name, "") + consultants = await consultant_api_service.get_api_consultants(consultant_name, "", "", "", "", "") + + if len(projects) == 0: + raise HttpError(404, f"Project not found: {project_name}") + elif len(projects) > 1: + raise HttpError(406, f"Multiple projects found with the name: {project_name}") + elif len(consultants) == 0: + raise HttpError(404, f"Consultant not found: {consultant_name}") + elif len(consultants) > 1: + raise HttpError(406, f"Multiple consultants found with the name: {consultant_name}") + + project = projects[0] + consultant = consultants[0] + + # Always charge to the current month + remaining_forecast = await assignment_db_service.add_consultant_to_project(project.id, consultant.id, role, hours) + message = f"Added consultant {consultant.name} to {project.clientName} on project '{project.name}' with {remaining_forecast} hours forecast this month." + + return ApiAddConsultantToProjectResponse( + clientName=project.clientName, + projectName=project.name, + consultantName=consultant.name, + remainingForecast=remaining_forecast, + message=message + ) + + +# This would initialize the service in a similar way to the TypeScript `export default` +project_api_service = ProjectApiService() \ No newline at end of file diff --git a/samples/cext-trey-research-python/services/project_db_service.py b/samples/cext-trey-research-python/services/project_db_service.py new file mode 100644 index 00000000..130cf38b --- /dev/null +++ b/samples/cext-trey-research-python/services/project_db_service.py @@ -0,0 +1,40 @@ +from .db_service import DbService +from model.db_model import DbProject +from model.base_model import Project +import logging + +TABLE_NAME = "Project" + +class ProjectDbService: + + def __init__(self): + # NOTE: Projects are READ ONLY in this demo app, so we are free to cache them in memory. + self.db_service = DbService[DbProject](ok_to_cache_locally=True) + + async def get_project_by_id(self, id: str) -> Project: + db_project = await self.db_service.get_entity_by_row_key(TABLE_NAME, id) + return self.convert_db_project(db_project) + + async def get_projects(self) -> list[Project]: + db_projects = await self.db_service.get_entities(TABLE_NAME) + return [self.convert_db_project(p) for p in db_projects] + + def convert_db_project(self, db_project: DbProject) -> Project: + return Project( + id=db_project.get('id'), + name=db_project.get('name'), + description=db_project.get('description'), + clientName=db_project.get('clientName'), + clientContact=db_project.get('clientContact'), + clientEmail=db_project.get('clientEmail'), + location=db_project.get('location'), + mapUrl=self.get_map_url(db_project) + ) + + def get_map_url(self, project: Project) -> str: + company_name_kabob_case = project.get('clientName').lower().replace(" ", "-") + return f"https://microsoft.github.io/copilot-camp/demo-assets/images/maps/{company_name_kabob_case}.jpg" + + +# Create an instance of ProjectDbService to use as a singleton +project_db_service = ProjectDbService() \ No newline at end of file diff --git a/samples/cext-trey-research-python/services/utilities.py b/samples/cext-trey-research-python/services/utilities.py new file mode 100644 index 00000000..c595b4a6 --- /dev/null +++ b/samples/cext-trey-research-python/services/utilities.py @@ -0,0 +1,30 @@ +import logging + +class HttpError(Exception): + """Exception class for HTTP errors.""" + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + +def clean_up_parameter(name: str, value: str) -> str: + """Cleans up common issues with parameters.""" + val = value.lower() + + if "trey" in val or "research" in val: + new_val = val.replace("trey", "").replace("research", "").strip() + logging.warning(f" ❗ Plugin name detected in the {name} parameter '{val}'; replacing with '{new_val}'.") + val = new_val + + if val == "": + logging.warning(f" ❗ Invalid name '{val}'; replacing with 'avery'.") + val = "avery" + + if name == "role" and val == "consultant": + logging.warning(f" ❗ Invalid role name '{val}'; replacing with ''.") + val = "" + + if val == "null": + logging.warning(f" ❗ Invalid value '{val}'; replacing with ''.") + val = "" + + return val \ No newline at end of file diff --git a/samples/cext-trey-research-python/teamsapp.local.yml b/samples/cext-trey-research-python/teamsapp.local.yml new file mode 100644 index 00000000..2eaa8357 --- /dev/null +++ b/samples/cext-trey-research-python/teamsapp.local.yml @@ -0,0 +1,68 @@ +# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.2/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.2 + +additionalMetadata: + sampleTag: Copilot-for-M365-Samples:cext-trey-research-python + +provision: + # Creates a Teams app + - uses: teamsApp/create + with: + # Teams app name + name: cexttreyresearch${{APP_NAME_SUFFIX}} + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + teamsAppId: TEAMS_APP_ID + + - uses: script + with: + run: + echo "::set-teamsfx-env SECRET_STORAGE_ACCOUNT_CONNECTION_STRING=UseDevelopmentStorage=true >> ./env/.env.local.user + + # Validate using manifest schema + #- uses: teamsApp/validateManifest + #with: + # Path to manifest template + #manifestPath: ./appManifest/manifest.json + + # Build Teams app package with latest env value + - uses: teamsApp/zipAppPackage + with: + # Path to manifest template + manifestPath: ./appManifest/manifest.json + outputZipPath: ./appManifest/build/appManifest.${{TEAMSFX_ENV}}.zip + outputJsonPath: ./appManifest/build/manifest.${{TEAMSFX_ENV}}.json + # Validate app package using validation rules + #- uses: teamsApp/validateAppPackage + #with: + # Relative path to this file. This is the path for built zip file. + #appPackagePath: ./appManifest/build/appManifest.${{TEAMSFX_ENV}}.zip + + # Apply the Teams app manifest to an existing Teams app in + # Teams Developer Portal. + # Will use the app id in manifest file to determine which Teams app to update. + - uses: teamsApp/update + with: + # Relative path to this file. This is the path for built zip file. + appPackagePath: ./appManifest/build/appManifest.${{TEAMSFX_ENV}}.zip + + - uses: teamsApp/extendToM365 + with: + # Relative path to the build app package. + appPackagePath: ./appManifest/build/appManifest.${{TEAMSFX_ENV}}.zip + # Write the information of created resources into environment file for + # the specified environment variable(s). + writeToEnvironmentFile: + titleId: M365_TITLE_ID + appId: M365_APP_ID + +deploy: + # Generate runtime environment variables + - uses: file/createOrUpdateEnvironmentFile + with: + target: ./.env + envs: + STORAGE_ACCOUNT_CONNECTION_STRING: ${{SECRET_STORAGE_ACCOUNT_CONNECTION_STRING}} \ No newline at end of file diff --git a/samples/cext-trey-research-python/teamsapp.yml b/samples/cext-trey-research-python/teamsapp.yml new file mode 100644 index 00000000..c5ce3435 --- /dev/null +++ b/samples/cext-trey-research-python/teamsapp.yml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.2/yaml.schema.json +# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file +# Visit https://aka.ms/teamsfx-actions for details on actions +version: v1.2 + +additionalMetadata: + sampleTag: Copilot-for-M365-Samples:cext-trey-research-python + +environmentFolderPath: ./env