diff --git a/README.md b/README.md index 7bd5037..7dc8081 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,20 @@ -## OFSC +## pyOFSC -A simple Python wrapper for Oracle OFS REST API +**Oracle Fusion Field Service** (formerly Oracle Field Service Cloud, formerly ETAdirect) is a cloud-based field service management platform for scheduling, dispatching, and managing mobile workforces. + +**pyOFSC** is a Python wrapper for its REST API, providing both synchronous and asynchronous clients with Pydantic model-based validation. See the [official Oracle Fusion Field Service documentation](https://docs.oracle.com/en/cloud/saas/field-service/index.html) for API details. ## Async Client Starting with version 2.19, pyOFSC includes an async client (`AsyncOFSC`) that provides asynchronous API access using `httpx` and Python's `async`/`await` patterns. -**Implementation Status**: The async client is being implemented progressively. Currently available async methods are marked with `[Sync & Async]` tags throughout this documentation. +**Implementation Status**: The async client is being implemented progressively. Currently available async methods are marked with `[Sync & Async]` tags in [docs/ENDPOINTS.md](docs/ENDPOINTS.md). ### Usage Example ```python from ofsc.async_client import AsyncOFSC - -async with AsyncOFSC( - clientID="your_client_id", - secret="your_secret", - companyName="your_company" -) as client: - # Get workzones asynchronously - workzones = await client.metadata.get_workzones(offset=0, limit=100) - - # Get specific workzone - workzone = await client.metadata.get_workzone("ATLANTA") - - # Create a new workzone - from ofsc.models import Workzone - new_zone = Workzone( - workZoneLabel="NEW_ZONE", - workZoneName="New Zone", - status="active", - travelArea="enterprise" - ) - result = await client.metadata.create_workzone(new_zone) +async with AsyncOFSC(clientID="...", secret="...", companyName="...") as client: + workzones = await client.metadata.get_workzones() ``` ### Key Features @@ -39,83 +22,27 @@ async with AsyncOFSC( - **Same Models**: Reuses all existing Pydantic models from the sync version - **Context Manager**: Must be used as an async context manager to properly manage HTTP client lifecycle - **Simplified API**: Async methods always return Pydantic models (no `response_type` parameter) +- **Request/Response Logging**: Optional httpx event hooks for automatic API call tracing + +### Enabling Request/Response Logging + +Pass `enable_logging=True` to automatically log all HTTP requests and responses via Python's standard logging: + +```python +import logging +logging.basicConfig(level=logging.DEBUG) -### Currently Implemented Async Methods -- **Metadata / Workzones**: `get_workzones`, `get_workzone`, `create_workzone`, `replace_workzone` +async with AsyncOFSC(clientID="...", secret="...", companyName="...", enable_logging=True) as client: + workzones = await client.metadata.get_workzones() + # DEBUG: Request: GET https://company.fs.ocs.oraclecloud.com/rest/ofscMetadata/v1/workZones + # DEBUG: Response: GET https://company.fs.ocs.oraclecloud.com/rest/ofscMetadata/v1/workZones 200 +``` -More async methods will be added progressively. Check the `[Sync & Async]` tags in the function listings below to see which methods support async. +Logs are emitted under the `ofsc.async_client` logger. HTTP errors (4xx/5xx) are also logged at WARNING level. Disabled by default with zero overhead. ## Models -Starting with OFS 1.17 we added models for the most common entities and metadata. All models should be imported from `ofsc.models`. All existing create functions will be eventually transitioned to models. - -The models are based on the Pydantic BaseModel, so it is possible to build an entity using the `model_validate` static methods. - -### Core Models -- **Activity**: Main activity entity with all properties -- **Resource**: Resource entity (users, technicians, etc.) -- **ResourceType**: Resource type definitions -- **Location**: Geographic locations and resource locations -- **AssignedLocation**: Location assignments for resources -- **BaseUser**: User entity for resource management - -### Metadata Models -- **ActivityTypeGroup**: Activity type group definitions -- **ActivityType**: Activity type definitions with colors, features, and time slots -- **CapacityArea**: Capacity area definitions with parent relationships -- **CapacityCategory**: Capacity category definitions -- **InventoryType**: Inventory type definitions -- **Property**: Property definitions with validation and enumeration support -- **EnumerationValue**: Enumeration values for properties -- **RoutingProfile**: Routing profile definitions (groups of routing plans) -- **RoutingPlan**: Routing plan definitions -- **RoutingPlanData**: Complete routing plan export with configuration -- **RoutingPlanConfig**: Detailed routing plan configuration with optimization parameters -- **RoutingActivityGroup**: Activity group configuration within routing plan -- **RoutingProviderGroup**: Provider group settings within activity group -- **Workskill**: Work skill definitions -- **WorkSkillCondition**: Work skill condition definitions -- **WorkSkillGroup**: Work skill group definitions -- **Workzone**: Work zone definitions with keys, shapes, and organization -- **WorkzoneListResponse**: Paginated response for workzone lists - -### Organization & Application Models -- **Application**: Application definitions with resource access -- **Organization**: Organization entity definitions - -### Bulk Operations Models -- **BulkUpdateRequest**: Request model for bulk activity updates -- **BulkUpdateResponse**: Response model with results, errors, and warnings -- **BulkUpdateActivityItem**: Individual activity item for bulk operations - -### Schedule & Calendar Models -- **ResourceWorkScheduleItem**: Work schedule definitions for resources -- **CalendarView**: Calendar view with shifts and time slots -- **CalendarViewItem**: Individual calendar items with recurrence support -- **Recurrence**: Recurrence pattern definitions - -### Daily Extract Models -- **DailyExtractFolders**: Available extract date folders -- **DailyExtractFiles**: Available files for a specific date -- **DailyExtractItem**: Individual extract file information - -### Capacity Models -- **CapacityRequest**: Request model for capacity queries with CsvList support for string arrays (areas, dates, categories) -- **GetCapacityResponse**: Response model for capacity data -- **GetQuotaRequest**: Request model for quota queries with automatic CsvList conversion for string arrays -- **GetQuotaResponse**: Response model for quota data -- **CapacityResponseItem**: Individual capacity response item by date -- **CapacityAreaResponseItem**: Capacity area response with metrics and categories -- **CapacityMetrics**: Capacity metrics with count and optional minutes arrays -- **CapacityCategoryItem**: Capacity category items with calendar and available metrics -- **QuotaAreaItem**: Quota area response with quota-specific fields (maxAvailable, used, bookedActivities, etc.) - -### Configuration & Utility Models -- **OFSConfig**: Main configuration model for API connection -- **OFSResponseList**: Generic paginated response wrapper -- **CsvList**: Auxiliary model for comma-separated string lists with conversion methods -- **Translation**: Multi-language translation support -- **OFSAPIError**: Standardized API error responses +All API entities use Pydantic v2 models. See `ofsc/models/` for available models. ## Testing @@ -162,533 +89,29 @@ uv run pytest tests/async/test_async_workzones.py - `@pytest.mark.slow` - Slow-running tests - `@pytest.mark.integration` - Integration tests -## Functions implemented - - - -### Core / Activities - get_activities(self, params, response_type=OBJ_RESPONSE) [Sync & Async] - get_activity(self, activity_id, response_type=OBJ_RESPONSE) [Sync & Async] - create_activity(self, activity: Activity) [Async] - update_activity(self, activity_id, data, response_type=OBJ_RESPONSE) [Sync & Async] - delete_activity(self, activity_id) [Sync & Async] - move_activity(self, activity_id, data, response_type=OBJ_RESPONSE) - search_activities(self, params, response_type=OBJ_RESPONSE) - bulk_update(self, data: BulkUpdateRequest, response_type=OBJ_RESPONSE) - get_file_property(self, activityId, label, mediaType="application/octet-stream", response_type=OBJ_RESPONSE) [Sync & Async] - set_file_property(self, activity_id, label, content, media_type, filename=None) [Async] - delete_file_property(self, activity_id, label) [Async] - get_customer_inventories(self, activity_id, offset=0, limit=100) [Async] - create_customer_inventory(self, activity_id, inventory: Inventory) [Async] - get_installed_inventories(self, activity_id, offset=0, limit=100) [Async] - get_deinstalled_inventories(self, activity_id, offset=0, limit=100) [Async] - get_linked_activities(self, activity_id) [Async] - link_activities(self, activity_id, link: LinkedActivity) [Async] - unlink_activities(self, activity_id) [Async] - get_activity_link(self, activity_id, linked_activity_id, link_type) [Async] - set_activity_link(self, activity_id, linked_activity_id, link_type, data) [Async] - delete_activity_link(self, activity_id, linked_activity_id, link_type) [Async] - get_required_inventories(self, activity_id) [Async] - set_required_inventories(self, activity_id, inventories: list[RequiredInventory]) [Async] - delete_required_inventories(self, activity_id) [Async] - get_resource_preferences(self, activity_id) [Async] - set_resource_preferences(self, activity_id, preferences: list[ResourcePreference]) [Async] - delete_resource_preferences(self, activity_id) [Async] - get_capacity_categories(self, activity_id) [Async] - get_submitted_forms(self, activity_id, offset=0, limit=100) [Async] - get_multiday_segments(self, activity_id) [Async] - get_all_activities(self, root=None, date_from=date.today()-timedelta(days=7), date_to=date.today()+timedelta(days=7), activity_fields=["activityId", "activityType", "date", "resourceId", "status"], additional_fields=None, initial_offset=0, include_non_scheduled=False, limit=5000) - - -### Core / Events - get_subscriptions(self, response_type=OBJ_RESPONSE) - create_subscription(self, data, response_type=OBJ_RESPONSE) - delete_subscription(self, subscription_id, response_type=OBJ_RESPONSE) - get_subscription_details(self, subscription_id, response_type=OBJ_RESPONSE) - get_events(self, params, response_type=OBJ_RESPONSE) - -### Core / Resources - get_resource(self, resource_id, inventories=False, workSkills=False, workZones=False, workSchedules=False, response_type=OBJ_RESPONSE) [Sync & Async] - get_resources(self, fields=None, offset=0, limit=100, canBeTeamHolder=None, canParticipateInTeam=None, inventories=False, workSkills=False, workZones=False, workSchedules=False, response_type=OBJ_RESPONSE) [Sync & Async] - create_resource(self, resourceId, data, response_type=OBJ_RESPONSE) [Sync & Async] - create_resource_from_obj(self, resourceId, data, response_type=OBJ_RESPONSE) [Sync & Async] - update_resource(self, resourceId, data: dict, identify_by_internal_id: bool = False, response_type=OBJ_RESPONSE) [Sync & Async] - get_position_history(self, resource_id, date, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_route(self, resource_id, date, activityFields=None, offset=0, limit=100, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_descendants(self, resource_id, resourceFields=None, offset=0, limit=100, inventories=False, workSkills=False, workZones=False, workSchedules=False, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_users(self, resource_id, response_type=OBJ_RESPONSE) [Sync & Async] - set_resource_users(self, resource_id, users: tuple[str], response_type=OBJ_RESPONSE) [Sync & Async] - delete_resource_users(self, resource_id, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_workschedules(self, resource_id, actualDate: date, response_type=OBJ_RESPONSE) [Sync & Async] - set_resource_workschedules(self, resource_id, data: ResourceWorkScheduleItem, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_calendar(self, resource_id: str, dateFrom: date, dateTo: date, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_inventories(self, resource_id, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_assigned_locations(self, resource_id, response_type=OBJ_RESPONSE) - get_resource_workzones(self, resource_id, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_workskills(self, resource_id, response_type=OBJ_RESPONSE) [Sync & Async] - bulk_update_resource_workzones(self, data, response_type=OBJ_RESPONSE) [Sync & Async] - bulk_update_resource_workskills(self, data, response_type=OBJ_RESPONSE) [Sync & Async] - bulk_update_resource_workschedules(self, data, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_locations(self, resource_id, response_type=OBJ_RESPONSE) [Sync & Async] - create_resource_location(self, resource_id, location: Location, response_type=OBJ_RESPONSE) [Sync & Async] - delete_resource_location(self, resource_id, location_id, response_type=OBJ_RESPONSE) [Sync & Async] - get_assigned_locations(self, resource_id, dateFrom: date = date.today(), dateTo: date = date.today(), response_type=OBJ_RESPONSE) [Sync & Async] - set_assigned_locations(self, resource_id: str, data: AssignedLocationsResponse, response_type=OBJ_RESPONSE) [Sync & Async] - get_resource_assistants(self, resource_id) [Async] - get_resource_children(self, resource_id, offset=0, limit=100) [Async] - get_resource_location(self, resource_id, location_id) [Async] - get_resource_plans(self, resource_id) [Async] - get_calendars() [Async] - create_resource_inventory(self, resource_id, inventory_data) [Async] - install_resource_inventory(self, resource_id, inventory_id) [Async] - set_resource_workskills(self, resource_id, workskills) [Async] - delete_resource_workskill(self, resource_id, workskill) [Async] - set_resource_workzones(self, resource_id, workzones) [Async] - delete_resource_workzone(self, resource_id, workzone_item_id) [Async] - delete_resource_workschedule(self, resource_id, schedule_item_id) [Async] - update_resource_location(self, resource_id, location_id, data) [Async] - get_resource_file_property(self, resource_id, property_label, media_type) [Async] - set_resource_file_property(self, resource_id, property_label, content, filename, content_type) [Async] - delete_resource_file_property(self, resource_id, property_label) [Async] - -### Core / Inventories (Standalone) - create_inventory(self, data) [Async] - get_inventory(self, inventory_id) [Async] - update_inventory(self, inventory_id, data) [Async] - delete_inventory(self, inventory_id) [Async] - get_inventory_property(self, inventory_id, label) [Async] - set_inventory_property(self, inventory_id, label, content, filename, content_type) [Async] - delete_inventory_property(self, inventory_id, label) [Async] - inventory_install(self, inventory_id, data=None) [Async] - inventory_deinstall(self, inventory_id, data=None) [Async] - inventory_undo_install(self, inventory_id, data=None) [Async] - inventory_undo_deinstall(self, inventory_id, data=None) [Async] - -### Core / Users - get_users(self, offset=0, limit=100, response_type=OBJ_RESPONSE) [Sync & Async] - get_user(self, login, response_type=OBJ_RESPONSE) [Sync & Async] - update_user(self, login, data, response_type=OBJ_RESPONSE) [Sync & Async] - create_user(self, login, data, response_type=OBJ_RESPONSE) [Sync & Async] - delete_user(self, login, response_type=OBJ_RESPONSE) [Sync & Async] - get_user_property(self, login, property_label) [Async] - set_user_property(self, login, property_label, content, filename, content_type) [Async] - delete_user_property(self, login, property_label) [Async] - get_user_collab_groups(self, login) [Async] - set_user_collab_groups(self, login, groups) [Async] - delete_user_collab_groups(self, login) [Async] - -### Core / Daily Extract - get_daily_extract_dates(self, response_type=OBJ_RESPONSE) - get_daily_extract_files(self, date, response_type=OBJ_RESPONSE) - get_daily_extract_file(self, date, filename, response_type=FILE_RESPONSE) - -### Core / Helper Functions - get_all_properties(self, initial_offset=0, limit=100) - -### Metadata / Activity Type Groups - get_activity_type_groups (self, expand="parent", offset=0, limit=100, response_type=OBJ_RESPONSE) - get_activity_type_group (self,label, response_type=OBJ_RESPONSE) - create_or_replace_activity_type_group(self, data: ActivityTypeGroup) [Async] - -### Metadata / Activity Types - get_activity_types(self, offset=0, limit=100, response_type=OBJ_RESPONSE) - get_activity_type (self, label, response_type=OBJ_RESPONSE) - create_or_replace_activity_type(self, data: ActivityType) [Async] - -### Metadata / Applications - get_applications(self, response_type=OBJ_RESPONSE) - get_application(self, label: str, response_type=OBJ_RESPONSE) - get_application_api_accesses(self, label: str, response_type=OBJ_RESPONSE) - get_application_api_access(self, label: str, accessId: str, response_type=OBJ_RESPONSE) - create_or_replace_application(self, data: Application) [Async] - update_application_api_access(self, label: str, api_label: str, data: dict) [Async] - generate_application_client_secret(self, label: str) [Async] - -### Metadata / Capacity - get_capacity_areas(self, expandParent: bool = False, fields: list[str] = ["label"], activeOnly: bool = False, areasOnly: bool = False, response_type=OBJ_RESPONSE) - get_capacity_area(self, label: str, response_type=OBJ_RESPONSE) - get_capacity_area_capacity_categories(self, label: str) [Async] - get_capacity_area_workzones(self, label: str) [Async] - get_capacity_area_workzones_v1(self, label: str) [Async] - get_capacity_area_time_slots(self, label: str) [Async] - get_capacity_area_time_intervals(self, label: str) [Async] - get_capacity_area_organizations(self, label: str) [Async] - get_capacity_area_children(self, label: str, status=None, fields=None, expand=None, type=None) [Async] - get_capacity_categories(self, offset=0, limit=100, response_type=OBJ_RESPONSE) - get_capacity_category(self, label: str, response_type=OBJ_RESPONSE) - create_or_replace_capacity_category(self, data: CapacityCategory) [Async] - delete_capacity_category(self, label: str) [Async] - -### Metadata / Forms - get_forms(self, offset=0, limit=100) [Async] - get_form(self, label: str) [Async] - create_or_replace_form(self, data: Form) [Async] - delete_form(self, label: str) [Async] - -### Metadata / Inventory - get_inventory_types(self, response_type=OBJ_RESPONSE) - get_inventory_type(self, label: str, response_type=OBJ_RESPONSE) - create_or_replace_inventory_type(self, data: InventoryType) [Async] - -### Metadata / Link Templates - get_link_templates(self, offset=0, limit=100) [Async] - get_link_template(self, label: str) [Async] - create_link_template(self, data: LinkTemplate) [Async] - update_link_template(self, data: LinkTemplate) [Async] - -### Metadata / Map Layers - get_map_layers(self, offset=0, limit=100) [Async] - get_map_layer(self, label: str) [Async] - create_or_replace_map_layer(self, data: MapLayer) [Async] - create_map_layer(self, data: MapLayer) [Async] - populate_map_layers(self, data: bytes | Path) [Async] - get_populate_map_layers_status(self, download_id: int) [Async] - -### Metadata / Plugins - import_plugin(self, plugin: str) - import_plugin_file(self, plugin: Path) - install_plugin(self, plugin_label: str) [Async] - -### Metadata / Properties - get_properties(self, offset=0, limit=100, response_type=OBJ_RESPONSE) - get_property(self, label: str, response_type=OBJ_RESPONSE) - create_or_replace_property(self, property: Property, response_type=OBJ_RESPONSE) - update_property(self, property: Property) [Async] - get_enumeration_values(self, label: str, offset=0, limit=100, response_type=OBJ_RESPONSE) - create_or_update_enumeration_value(self, label: str, value: Tuple[EnumerationValue, ...], response_type=OBJ_RESPONSE) - -### Metadata / Resource Types - get_resource_types(self, response_type=OBJ_RESPONSE) - -### Metadata / Routing Profiles - get_routing_profiles(self, offset=0, limit=100, response_type=OBJ_RESPONSE) - get_routing_profile_plans(self, profile_label: str, offset=0, limit=100, response_type=OBJ_RESPONSE) - export_routing_plan(self, profile_label: str, plan_label: str, response_type=OBJ_RESPONSE) - export_plan_file(self, profile_label: str, plan_label: str) -> bytes - import_routing_plan(self, profile_label: str, plan_data: bytes, response_type=OBJ_RESPONSE) - force_import_routing_plan(self, profile_label: str, plan_data: bytes, response_type=OBJ_RESPONSE) - start_routing_plan(self, profile_label: str, plan_label: str, resource_external_id: str, date: str, response_type=OBJ_RESPONSE) - -### Metadata / Shifts - get_shifts(self, offset=0, limit=100) [Async] - get_shift(self, label: str) [Async] - create_or_replace_shift(self, data: Shift) [Async] - delete_shift(self, label: str) [Async] - -### Metadata / Workskills - get_workskills (self, offset=0, limit=100, response_type=OBJ_RESPONSE) - get_workskill(self, label: str, response_type=OBJ_RESPONSE) - create_or_update_workskill(self, skill: Workskill, response_type=OBJ_RESPONSE) - delete_workskill(self, label: str, response_type=OBJ_RESPONSE) - get_workskill_conditions(self, response_type=OBJ_RESPONSE) - replace_workskill_conditions(self, data: WorskillConditionList, response_type=OBJ_RESPONSE) - get_workskill_groups(self, response_type=OBJ_RESPONSE) - get_workskill_group(self, label: str, response_type=OBJ_RESPONSE) - create_or_update_workskill_group(self, group: WorkSkillGroup, response_type=OBJ_RESPONSE) - delete_workskill_group(self, label: str, response_type=OBJ_RESPONSE) - -### Metadata / Workzones [Sync & Async] - get_workzones(self, offset=0, limit=100, response_type=OBJ_RESPONSE) - get_workzone(self, label: str, response_type=OBJ_RESPONSE) - create_workzone(self, workzone: Workzone, response_type=OBJ_RESPONSE) # Async only - replace_workzone(self, workzone: Workzone, auto_resolve_conflicts: bool = False, response_type=OBJ_RESPONSE) - replace_workzones(self, data: list[Workzone]) [Async] - update_workzones(self, data: list[Workzone]) [Async] - populate_workzone_shapes(self, data: bytes | Path) [Async] - get_populate_workzone_shapes_status(self, download_id: int) [Async] - get_workzone_key(self) [Async] - -### Metadata / Organizations - get_organizations(self, response_type=OBJ_RESPONSE) - get_organization(self, label: str, response_type=OBJ_RESPONSE) - -### Capacity / Available Capacity - getAvailableCapacity(self, dates, areas, categories=None, aggregateResults=None, availableTimeIntervals="all", calendarTimeIntervals="all", fields=None, response_type=OBJ_RESPONSE) [Sync & Async] - get_available_capacity(self, dates, areas=None, categories=None, aggregateResults=None, availableTimeIntervals="all", calendarTimeIntervals="all", fields=None) [Async] - getQuota(self, dates, areas=None, categories=None, aggregateResults=None, categoryLevel=None, intervalLevel=None, returnStatuses=None, timeSlotLevel=None, response_type=OBJ_RESPONSE) [Sync & Async] - get_quota(self, dates, areas=None, categories=None, aggregateResults=None, categoryLevel=None, intervalLevel=None, returnStatuses=None, timeSlotLevel=None) [Async] - update_quota(self, data) [Async] - get_activity_booking_options(self, dates, areas=None, activityType=None, duration=None, ...) [Async] - get_booking_closing_schedule(self, dates, areas=None) [Async] - update_booking_closing_schedule(self, data) [Async] - get_booking_statuses(self, dates, areas=None) [Async] - update_booking_statuses(self, data) [Async] - show_booking_grid(self, data) [Async] - get_booking_fields_dependencies(self, areas=None) [Async] - -### Statistics / Activity Duration Stats - get_activity_duration_stats(self, resource_id=None, include_children=None, akey=None, offset=0, limit=100) [Async] - update_activity_duration_stats(self, data) [Async] - -### Statistics / Activity Travel Stats - get_activity_travel_stats(self, region=None, tkey=None, fkey=None, key_id=None, offset=0, limit=100) [Async] - update_activity_travel_stats(self, data) [Async] - -### Statistics / Airline Distance Based Travel - get_airline_distance_based_travel(self, level=None, key=None, distance=None, key_id=None, offset=0, limit=100) [Async] - update_airline_distance_based_travel(self, data) [Async] - -### Auth / OAuth2 - get_token(self, request: OFSOAuthRequest = OFSOAuthRequest()) [Async] +## Implemented Functions -## Usage Examples +**195 async endpoints** (80% coverage) and **89 sync endpoints** (37% coverage) across Core, Metadata, Capacity, Statistics, and Auth modules. -### Capacity API -```python -from ofsc import OFSC -from ofsc.models import CsvList - -# Initialize connection -ofsc_instance = OFSC( - clientID="your_client_id", - secret="your_secret", - companyName="your_company" -) - -# Get capacity data with individual parameters -response = ofsc_instance.capacity.getAvailableCapacity( - dates=["2025-06-25", "2025-06-26"], # Required - areas=["Atlantic", "Pacific"], # Required - availableTimeIntervals="all", # Optional - calendarTimeIntervals="all" # Optional -) - -# Access response data -for item in response.items: - print(f"Date: {item.date}") - for area in item.areas: - print(f" Area: {area.label}") - print(f" Calendar count: {area.calendar.count}") - if area.available: - print(f" Available count: {area.available.count}") - -# Alternative input formats also work: -# CSV string format -response = ofsc_instance.capacity.getAvailableCapacity( - dates="2025-06-25,2025-06-26", - areas="Atlantic,Pacific", - categories="Install,Repair" -) - -# CsvList format -response = ofsc_instance.capacity.getAvailableCapacity( - dates=CsvList.from_list(["2025-06-25"]), - areas=CsvList.from_list(["Atlantic"]), - aggregateResults=True -) -``` +See [docs/ENDPOINTS.md](docs/ENDPOINTS.md) for the full implementation status table. -### Quota API with CsvList -```python -from ofsc.models import GetQuotaRequest, CsvList - -# Create quota request with list[str] (automatically converted to CsvList) -quota_request = GetQuotaRequest( - aggregateResults=True, - areas=["Atlantic", "Pacific"], # list[str] - auto-converted - categories=["Install", "Repair"], # list[str] - auto-converted - categoryLevel=True, - dates=["2025-06-25", "2025-06-26"], # list[str] - auto-converted - intervalLevel=False, - returnStatuses=True, - timeSlotLevel=False -) - -# Or with CsvList directly -quota_request2 = GetQuotaRequest( - aggregateResults=False, - areas=CsvList.from_list(["Europe", "Asia"]), # CsvList input - categories="Service,Support", # CSV string - auto-converted - categoryLevel=False, - dates=["2025-06-27", "2025-06-28"], - intervalLevel=True, - returnStatuses=False, - timeSlotLevel=True -) - -# Access as lists -areas_list = quota_request.get_areas_list() # ["Atlantic", "Pacific"] -categories_list = quota_request.get_categories_list() # ["Install", "Repair"] -``` +## Usage Examples -### Quota API Function +**Sync:** ```python from ofsc import OFSC - -# Initialize connection -ofsc_instance = OFSC( - clientID="your_client_id", - secret="your_secret", - companyName="your_company" -) - -# Simple quota request with individual parameters -quota_response = ofsc_instance.capacity.getQuota( - dates=["2025-06-25", "2025-06-26"], # Required - areas=["Atlantic", "Pacific"], # Optional - aggregateResults=True, # Optional - categoryLevel=False # Optional -) - -# Minimal quota request (only required dates) -minimal_quota = ofsc_instance.capacity.getQuota( - dates=["2025-06-27"] - # All other parameters default to None -) - -# Mixed input types -mixed_quota = ofsc_instance.capacity.getQuota( - dates="2025-06-28,2025-06-29", # CSV string - areas=["Europe", "Asia"], # List - categories="Install,Repair", # CSV string - returnStatuses=True -) +instance = OFSC(clientID="...", secret="...", companyName="...") +workzones = instance.metadata.get_workzones() ``` -### Routing Profiles API +**Async:** ```python -from ofsc import OFSC -from ofsc.common import FULL_RESPONSE - -# Initialize connection -ofsc_instance = OFSC( - clientID="your_client_id", - secret="your_secret", - companyName="your_company" -) - -# Get all routing profiles -profiles = ofsc_instance.metadata.get_routing_profiles() -for profile in profiles.items: - print(f"Profile: {profile.profileLabel}") - -# Get plans for a specific profile -plans = ofsc_instance.metadata.get_routing_profile_plans( - profile_label="MaintenanceRoutingProfile" -) -for plan in plans.items: - print(f"Plan: {plan.planLabel}") - -# Export a routing plan (returns parsed JSON) -plan_data = ofsc_instance.metadata.export_routing_plan( - profile_label="MaintenanceRoutingProfile", - plan_label="Optimization" -) - -# Export a plan as raw bytes (ready for import) -plan_bytes = ofsc_instance.metadata.export_plan_file( - profile_label="MaintenanceRoutingProfile", - plan_label="Optimization" -) -# plan_bytes contains raw data that can be imported - -# Import a routing plan (409 if plan already exists) -response = ofsc_instance.metadata.import_routing_plan( - profile_label="TargetProfile", - plan_data=plan_bytes, - response_type=FULL_RESPONSE -) -if response.status_code == 409: - print("Plan already exists, use force_import to overwrite") - -# Force import (overwrite existing plan) -response = ofsc_instance.metadata.force_import_routing_plan( - profile_label="MaintenanceRoutingProfile", - plan_data=plan_bytes, - response_type=FULL_RESPONSE -) -print(f"Import status: {response.status_code}") - -# Start a routing plan for a specific resource -response = ofsc_instance.metadata.start_routing_plan( - profile_label="MaintenanceRoutingProfile", - plan_label="Optimization", - resource_external_id="TECH_001", - date="2025-10-25", - response_type=FULL_RESPONSE -) -print(f"Start status: {response.status_code}") - -# Complete workflow: Backup and restore a routing plan -# 1. Export the plan -backup_data = ofsc_instance.metadata.export_plan_file( - profile_label="MaintenanceRoutingProfile", - plan_label="Optimization" -) - -# 2. Save to file (optional) -with open("backup_optimization.dat", "wb") as f: - f.write(backup_data) - -# 3. Later, restore from backup -with open("backup_optimization.dat", "rb") as f: - restore_data = f.read() - -response = ofsc_instance.metadata.force_import_routing_plan( - profile_label="MaintenanceRoutingProfile", - plan_data=restore_data, - response_type=FULL_RESPONSE -) -print(f"Restore completed: {response.status_code}") +from ofsc.async_client import AsyncOFSC +async with AsyncOFSC(clientID="...", secret="...", companyName="...") as client: + workzones = await client.metadata.get_workzones() ``` -### Workzones API -```python -from ofsc import OFSC -from ofsc.models import Workzone - -# Initialize connection -ofsc_instance = OFSC( - clientID="your_client_id", - secret="your_secret", - companyName="your_company" -) - -# Get all workzones (returns WorkzoneListResponse) -workzones = ofsc_instance.metadata.get_workzones(offset=0, limit=100) -print(f"Total workzones: {workzones.totalResults}") - -for workzone in workzones.items: - print(f"Label: {workzone.workZoneLabel}, Name: {workzone.workZoneName}") - print(f" Status: {workzone.status}, Travel Area: {workzone.travelArea}") - if workzone.keys: - print(f" Keys: {', '.join(workzone.keys)}") - if workzone.shapes: - print(f" Shapes: {', '.join(workzone.shapes)}") - -# Get a single workzone by label (returns Workzone) -workzone = ofsc_instance.metadata.get_workzone("ATLANTA") -print(f"Workzone: {workzone.workZoneName}") -print(f"Status: {workzone.status}") - -# Replace/Update a workzone -updated_workzone = Workzone( - workZoneLabel="ATLANTA", - workZoneName="Atlanta Metro Area", - status="active", - travelArea="sunrise_enterprise", - keys=["ATL", "ATLANTA"], - shapes=["12345", "67890"], - organization="SOUTH_REGION" -) - -result = ofsc_instance.metadata.replace_workzone( - workzone=updated_workzone, - auto_resolve_conflicts=True # Automatically resolve key conflicts with other zones -) -print(f"Updated workzone: {result.workZoneLabel}") - -# Using FULL_RESPONSE for raw API response -from ofsc.common import FULL_RESPONSE - -response = ofsc_instance.metadata.get_workzone( - "ATLANTA", - response_type=FULL_RESPONSE -) -if response.status_code == 200: - data = response.json() - print(f"Raw workzone data: {data}") -``` +See the [examples/](examples/) directory for comprehensive sync and async usage examples. ## Test History @@ -700,34 +123,12 @@ OFS REST API Version | PyOFSC 22B| 1.16, 1.17 22D| 1.18 24C| 2.0 - -## Deprecation Warning - -Starting in OFSC 2.0 all functions are called using the API name (Core or Metadata). See the examples. - -Instead of - - instance = OFSC(..) - list_of_activities = instance.get_activities(...) - -It will be required to use the right API module: - - instance = OFSC(..) - list_of_activites = instance.core.get_activities(...) - -During the transition period a DeprecationWarning will be raised if the functions are used in the old way - -## What's new in OFSC 2.0 - -- All metadata functions now use models, when available -- All functions are now using the API name (Core or Metadata) -- All functions return a python object by default. If there is an available model it will be used, otherwise a dict will be returned (see `response_type` parameter and `auto_model` parameter) -- Errors during API calls can raise exceptions and will by default when returning an object (see `auto_raise` parameter) -- OBJ_RESPONS and TEXT_RESPONSE are now deprecated. Use `response_type` parameter to control the response type +25B| 2.12 +26A| 2.24.0 ## Future Deprecation Notice - OFSC 3.0 -**Important**: Starting with OFSC 3.0, the synchronous client (`OFSC`) will be deprecated in favor of the async client (`AsyncOFSC`). +**Important**: Starting with Oracle Fusion Field Service 3.0, the synchronous client (`OFSC`) will be deprecated in favor of the async client (`AsyncOFSC`). ### Migration Path - The async client (`AsyncOFSC`) is the recommended approach for all new development @@ -735,32 +136,7 @@ During the transition period a DeprecationWarning will be raised if the function - The compatibility wrapper will internally use the async client with synchronous adapters - We recommend gradually migrating to the async client to take advantage of better performance and scalability -### Migration Example -**Current synchronous code:** -```python -from ofsc import OFSC - -instance = OFSC( - clientID="your_client_id", - secret="your_secret", - companyName="your_company" -) -workzones = instance.metadata.get_workzones(offset=0, limit=100) -``` - -**Migrated async code:** -```python -from ofsc.async_client import AsyncOFSC - -async with AsyncOFSC( - clientID="your_client_id", - secret="your_secret", - companyName="your_company" -) as client: - workzones = await client.metadata.get_workzones(offset=0, limit=100) -``` - ### Timeline - **OFSC 2.x**: Both sync and async clients fully supported - **OFSC 3.0**: Sync client deprecated, compatibility wrapper provided -- **OFSC 4.0**: Sync client may be removed (compatibility wrapper will remain for at least one major version) \ No newline at end of file +- **OFSC 4.0**: Sync client may be removed (compatibility wrapper will remain for at least one major version) diff --git a/ofsc/async_client/__init__.py b/ofsc/async_client/__init__.py index 8d2c1cd..2f768ed 100644 --- a/ofsc/async_client/__init__.py +++ b/ofsc/async_client/__init__.py @@ -1,5 +1,6 @@ """Async version of the OFSC client using httpx.AsyncClient.""" +import logging from typing import Optional import httpx @@ -23,6 +24,8 @@ from .oauth import AsyncOFSOauth2 from .statistics import AsyncOFSStatistics +logger = logging.getLogger(__name__) + __all__ = [ "AsyncOFSC", "OFSAPIException", @@ -79,7 +82,9 @@ def __init__( access_token: Optional[str] = None, enable_auto_raise: bool = True, enable_auto_model: bool = True, + enable_logging: bool = False, ): + self._enable_logging = enable_logging self._config = OFSConfig( baseURL=baseUrl, clientID=clientID, @@ -100,7 +105,29 @@ def __init__( async def __aenter__(self) -> "AsyncOFSC": """Enter async context manager - create shared httpx.AsyncClient.""" - self._client = httpx.AsyncClient(http2=True) + + async def log_request(request: httpx.Request) -> None: + logger.debug("Request: %s %s", request.method, request.url) + + async def log_response(response: httpx.Response) -> None: + request = response.request + logger.debug("Response: %s %s %s", request.method, request.url, response.status_code) + if response.status_code >= 400: + logger.warning( + "HTTP error: %s %s %s", + request.method, + request.url, + response.status_code, + ) + + event_hooks: dict[str, list] = {} + if self._enable_logging: + event_hooks = { + "request": [log_request], + "response": [log_response], + } + + self._client = httpx.AsyncClient(http2=True, event_hooks=event_hooks) self._core = AsyncOFSCore(config=self._config, client=self._client) self._metadata = AsyncOFSMetadata(config=self._config, client=self._client) self._capacity = AsyncOFSCapacity(config=self._config, client=self._client) diff --git a/ofsc/async_client/core/_base.py b/ofsc/async_client/core/_base.py index e57ab71..53385e8 100644 --- a/ofsc/async_client/core/_base.py +++ b/ofsc/async_client/core/_base.py @@ -57,6 +57,8 @@ def config(self) -> OFSConfig: @property def baseUrl(self) -> str: + if self._config.baseURL is None: + raise ValueError("Base URL is not configured") return self._config.baseURL @property diff --git a/ofsc/async_client/oauth.py b/ofsc/async_client/oauth.py index c1ff4e4..45354e0 100644 --- a/ofsc/async_client/oauth.py +++ b/ofsc/async_client/oauth.py @@ -30,6 +30,8 @@ def config(self) -> OFSConfig: @property def baseUrl(self) -> str: + if self._config.baseURL is None: + raise ValueError("Base URL is not configured") return self._config.baseURL @property diff --git a/ofsc/common.py b/ofsc/common.py index 2c83778..4d6d16d 100644 --- a/ofsc/common.py +++ b/ofsc/common.py @@ -5,6 +5,8 @@ from .exceptions import OFSAPIException +logger = logging.getLogger(__name__) + TEXT_RESPONSE = 1 FULL_RESPONSE = 2 OBJ_RESPONSE = 3 @@ -20,7 +22,7 @@ def wrap_return(*decorator_args, **decorator_kwargs): def decorator(func): @wraps(func) def wrapper(*func_args, **func_kwargs): - logging.debug(f"{func_args=}, {func_kwargs=}, {decorator_args=}, {decorator_kwargs=}") + logger.debug(f"{func_args=}, {func_kwargs=}, {decorator_args=}, {decorator_kwargs=}") config = func_args[0].config # Pre: response_type = func_kwargs.get("response_type", decorator_kwargs.get("response_type", OBJ_RESPONSE)) @@ -31,12 +33,12 @@ def wrapper(*func_args, **func_kwargs): response = func(*func_args, **func_kwargs) # post: - logging.debug(response) + logger.debug(response) if response_type == FULL_RESPONSE: return response elif response_type == OBJ_RESPONSE: - logging.debug(f"{response_type=}, {config.auto_model=}, {model=} {func_args= } {func_kwargs=}") + logger.debug(f"{response_type=}, {config.auto_model=}, {model=} {func_args= } {func_kwargs=}") if response.status_code in expected_codes: match response.status_code: case 204: @@ -55,7 +57,7 @@ def wrapper(*func_args, **func_kwargs): return response.json() # Check if response.statyus code is between 400 and 499 if 400 <= response.status_code < 500: - logging.error(response.json()) + logger.error(response.json()) raise OFSAPIException(**response.json()) elif 500 <= response.status_code < 600: raise OFSAPIException(**response.json()) @@ -68,7 +70,7 @@ def wrapper(*func_args, **func_kwargs): return response.json() # Check if response.statyus code is between 400 and 499 if 400 <= response.status_code < 500: - logging.error(response.json()) + logger.error(response.json()) raise OFSAPIException(**response.json()) elif 500 <= response.status_code < 600: raise OFSAPIException(**response.json()) diff --git a/ofsc/core.py b/ofsc/core.py index 1a74a1e..dc59870 100644 --- a/ofsc/core.py +++ b/ofsc/core.py @@ -23,6 +23,8 @@ ResourceWorkScheduleResponse, ) +logger = logging.getLogger(__name__) + class OFSCore(OFSApi): # OFSC Function Library @@ -117,14 +119,14 @@ def get_resource( @wrap_return(response_type=OBJ_RESPONSE, expected=[200]) def create_resource(self, resourceId, data): url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/resources/{resourceId}") - logging.debug(f"OFSC.Create_Resource: {data} {type(data)}") + logger.debug(f"OFSC.Create_Resource: {data} {type(data)}") response = requests.put(url, headers=self.headers, data=data) return response @wrap_return(response_type=OBJ_RESPONSE, expected=[200]) def create_resource_from_obj(self, resourceId, data): url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/resources/{resourceId}") - logging.debug(f"OFSC.Create_Resource: {data} {type(data)}") + logger.debug(f"OFSC.Create_Resource: {data} {type(data)}") response = requests.put(url, headers=self.headers, data=json.dumps(data)) return response @@ -134,7 +136,7 @@ def update_resource(self, resourceId, data: dict, identify_by_internal_id: bool if identify_by_internal_id: # add a query parameter to identify the resource by internal id url += "?identifyResourceBy=resourceInternalId" - logging.debug(f"OFSC.Update_Resource: {data} {type(data)}") + logger.debug(f"OFSC.Update_Resource: {data} {type(data)}") response = requests.patch(url, headers=self.headers, data=json.dumps(data)) return response @@ -205,7 +207,7 @@ def get_resource_descendants( params["fields"] = resourceFields params["limit"] = limit params["offset"] = offset - logging.debug(json.dumps(params, indent=2)) + logger.debug(json.dumps(params, indent=2)) response = requests.get(url, params=params, headers=self.headers) return response @@ -275,7 +277,7 @@ def get_resources( params["offset"] = offset params["limit"] = limit - logging.debug(json.dumps(params, indent=2)) + logger.debug(json.dumps(params, indent=2)) response = requests.get(url, params=params, headers=self.headers) return response @@ -421,7 +423,6 @@ def create_resource_location(self, resource_id, *, location: Location): self.baseUrl, f"/rest/ofscCore/v1/resources/{str(resource_id)}/locations", ) - print(location.model_dump(exclude="locationId", exclude_unset=True, exclude_none=True)) response = requests.post( url, headers=self.headers, @@ -568,9 +569,8 @@ def get_all_activities( "offset": offset, "limit": limit, } - logging.info(request_params) + logger.debug(request_params) response = self.get_activities(response_type=FULL_RESPONSE, params=request_params) - print(response.json()) response_body = response.json() if "items" in response_body.keys(): response_count = len(response_body["items"]) @@ -579,10 +579,10 @@ def get_all_activities( response_count = 0 if "hasMore" in response_body.keys(): hasMore = response_body["hasMore"] - logging.info("{},{},{}".format(offset, response_count, response.elapsed)) + logger.debug("{},{},{}".format(offset, response_count, response.elapsed)) else: hasMore = False - logging.info("{},{},{}".format(offset, response_count, response.elapsed)) + logger.debug("{},{},{}".format(offset, response_count, response.elapsed)) offset = offset + response_count return OFSResponseList(items=items) @@ -600,10 +600,10 @@ def get_all_properties(self, initial_offset=0, limit=100): response_count = 0 if "hasMore" in response_body.keys(): hasMore = response_body["hasMore"] - logging.info("{},{},{}".format(offset, response_count, response.elapsed)) + logger.debug("{},{},{}".format(offset, response_count, response.elapsed)) else: hasMore = False - logging.info("{},{},{}".format(offset, response_count, response.elapsed)) + logger.debug("{},{},{}".format(offset, response_count, response.elapsed)) offset = offset + response_count return items diff --git a/ofsc/models/_base.py b/ofsc/models/_base.py index fd874a7..bf08c28 100644 --- a/ofsc/models/_base.py +++ b/ofsc/models/_base.py @@ -25,6 +25,8 @@ from ..common import FULL_RESPONSE, wrap_return +logger = logging.getLogger(__name__) + # region Generic Models T = TypeVar("T") @@ -204,7 +206,7 @@ def baseUrl(self) -> str: @wrap_return(response_type=FULL_RESPONSE, expected=[200]) def token(self, auth: OFSOAuthRequest = OFSOAuthRequest()) -> requests.Response: headers = {} - logging.info(f"Getting token with {auth.grant_type}") + logger.info(f"Getting token with {auth.grant_type}") if auth.grant_type == "client_credentials" or auth.grant_type == "urn:ietf:params:oauth:grant-type:jwt-bearer": headers["Authorization"] = "Basic " + self._config.basicAuthString.decode("utf-8") else: @@ -232,7 +234,6 @@ def headers(self): else: self._token = self.token().json()["access_token"] self._headers["Authorization"] = f"Bearer {self._token}" - print(f"Not implemented {self._token}") return self._headers diff --git a/pyproject.toml b/pyproject.toml index 8cd0219..a8e6a57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ofsc" -version = "2.24.0" +version = "2.24.1" description = "Python wrapper for Oracle Field Service API" authors = [{ name = "Borja Toron", email = "borja.toron@gmail.com" }] requires-python = "~=3.11.0" diff --git a/tests/async/test_async_ofsc.py b/tests/async/test_async_ofsc.py index 1bdf034..b8438e4 100644 --- a/tests/async/test_async_ofsc.py +++ b/tests/async/test_async_ofsc.py @@ -1,5 +1,8 @@ """Tests for AsyncOFSC class.""" +import logging + +import httpx import pytest from ofsc.async_client import AsyncOFSC @@ -140,6 +143,93 @@ async def test_str_representation(self): assert "mycompany" in str(client) +class TestAsyncOFSCLogging: + """Test AsyncOFSC event hook logging.""" + + @pytest.mark.asyncio + async def test_logging_disabled_by_default(self): + """Test that logging is disabled by default.""" + client = AsyncOFSC( + clientID="test_client", + companyName="test_company", + secret="test_secret", + ) + assert client._enable_logging is False + + @pytest.mark.asyncio + async def test_logging_enabled_creates_event_hooks(self): + """Test that enabling logging configures httpx event hooks.""" + async with AsyncOFSC( + clientID="test_client", + companyName="test_company", + secret="test_secret", + enable_logging=True, + ) as client: + assert len(client._client.event_hooks["request"]) == 1 + assert len(client._client.event_hooks["response"]) == 1 + + @pytest.mark.asyncio + async def test_logging_disabled_no_event_hooks(self): + """Test that disabling logging results in no custom event hooks.""" + async with AsyncOFSC( + clientID="test_client", + companyName="test_company", + secret="test_secret", + enable_logging=False, + ) as client: + assert len(client._client.event_hooks["request"]) == 0 + assert len(client._client.event_hooks["response"]) == 0 + + @pytest.mark.asyncio + async def test_request_hook_logs_at_debug(self, caplog): + """Test that request hook logs at DEBUG level.""" + async with AsyncOFSC( + clientID="test_client", + companyName="test_company", + secret="test_secret", + enable_logging=True, + ) as client: + hook = client._client.event_hooks["request"][0] + request = client._client.build_request("GET", "https://example.com/test") + with caplog.at_level(logging.DEBUG, logger="ofsc.async_client"): + await hook(request) + assert "Request: GET https://example.com/test" in caplog.text + + @pytest.mark.asyncio + async def test_response_hook_logs_at_debug(self, caplog): + """Test that response hook logs at DEBUG level for successful responses.""" + async with AsyncOFSC( + clientID="test_client", + companyName="test_company", + secret="test_secret", + enable_logging=True, + ) as client: + hook = client._client.event_hooks["response"][0] + request = client._client.build_request("GET", "https://example.com/test") + response = httpx.Response(200, request=request) + with caplog.at_level(logging.DEBUG, logger="ofsc.async_client"): + await hook(response) + assert "Response: GET https://example.com/test 200" in caplog.text + + @pytest.mark.asyncio + async def test_response_hook_warns_on_http_error(self, caplog): + """Test that response hook logs a WARNING for 4xx/5xx status codes.""" + async with AsyncOFSC( + clientID="test_client", + companyName="test_company", + secret="test_secret", + enable_logging=True, + ) as client: + hook = client._client.event_hooks["response"][0] + request = client._client.build_request("GET", "https://example.com/test") + response = httpx.Response(404, request=request) + with caplog.at_level(logging.DEBUG, logger="ofsc.async_client"): + await hook(response) + assert "HTTP error: GET https://example.com/test 404" in caplog.text + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) == 1 + + class TestAsyncOFSMetadataStubs: """Test that AsyncOFSMetadata stub methods raise NotImplementedError.""" diff --git a/uv.lock b/uv.lock index 5d05c3e..30a2ca1 100644 --- a/uv.lock +++ b/uv.lock @@ -337,7 +337,7 @@ wheels = [ [[package]] name = "ofsc" -version = "2.24.0" +version = "2.24.1" source = { editable = "." } dependencies = [ { name = "cachetools" },