commit 515bd379520d5026e8ace77cff192a6fbae30181 Author: John Cardinal Date: Thu Jun 28 23:41:48 2018 +0000 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..b5ea129e --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,65 @@ +{ + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (web)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/server/AyaNova/bin/Debug/netcoreapp2.1/AyaNova.dll", + "args": [], + "cwd": "${workspaceFolder}/server/AyaNova", + "stopAtEntry": false, + "internalConsoleOptions": "openOnSessionStart", + "launchBrowser": { + "enabled": true, + "args": "${auto-detect-url}/api/v8/", + "windows": { + "command": "cmd.exe", + "args": "/C start http://localhost:7575/api/v8/" + }, + "osx": { + "command": "open" + }, + "linux": { + "command": "xdg-open" + } + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development", + "AYANOVA_LOG_LEVEL": "Info", + "AYANOVA_DEFAULT_LANGUAGE": "de", + //"AYANOVA_PERMANENTLY_ERASE_DATABASE": "true", + "AYANOVA_DB_CONNECTION":"Server=localhost;Username=postgres;Password=raven;Database=AyaNova;", + "AYANOVA_USE_URLS": "http://*:7575;", + "AYANOVA_FOLDER_USER_FILES": "c:\\temp\\RavenTestData\\userfiles", + "AYANOVA_FOLDER_BACKUP_FILES": "c:\\temp\\RavenTestData\\backupfiles", + "AYANOVA_METRICS_USE_INFLUXDB": "false" + + }, + "sourceFileMap": { + "/Views": "${workspaceFolder}/Views" + } + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} + +// "AYANOVA_DB_CONNECTION":"Server=localhost;Username=postgres;Password=raven;Database=ayanovadev", +//"AYANOVA_LOG_LEVEL": "Info" +//"AYANOVA_DB_CONNECTION":"Server=localhost;Username=postgres;Password=raven;Database=AyaNova", + +// "AYANOVA_PERMANENTLY_ERASE_DATABASE": "true", + +//Development system folders +//"AYANOVA_FOLDER_USER_FILES": "c:\\temp\\RavenTestData\\userfiles", +//"AYANOVA_FOLDER_BACKUP_FILES": "c:\\temp\\RavenTestData\\backupfiles", \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..7bda4df0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,19 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/server/AyaNova/AyaNova.csproj" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} \ No newline at end of file diff --git a/devdocs/coding-standards.txt b/devdocs/coding-standards.txt new file mode 100644 index 00000000..c96615d3 --- /dev/null +++ b/devdocs/coding-standards.txt @@ -0,0 +1,322 @@ +# “Do the simplest thing that will work.” + + + +#DISTRIBUTION / DEPLOYMENT +- Linux folders to use: + - Program files in /opt + - Data files in /var/lib + - Log files /var/log/ + + + + +#PROCESS + +- LEAST PRIVILEGE: Code everything for least privilege possible. The principle of “least privilege” mandates that a process should have the lowest level +of privilege needed to accomplish its task +- Test / Evaluation data generator developed hand in hand with testing and application code + - Data generator is very important, take the time to get it right + - Production size and complexity data is required for proper development right from the start + - As new modules are added need to add data generation for them as well +- Test driven development +- Dependency injection + - https://joonasw.net/view/aspnet-core-di-deep-dive +- Separation of concerns: a Customer object should not have to deal with it's persistence at all directly for example. +- Rapid release cycles of small features (every 2-4 weeks) +- NEVER select *, always specify columns, shouldn't be a problem with EF but remember this, it's for futureproofing and simultaneous versions running in api +- AGILE-like PROCESS + - (http://jack-vanlightly.com/blog/2017/9/17/you-dont-need-scrum-you-need-agility) + - At every decision favor whatever will in future involve the least amount of supervision by me or manual steps or work Roughly "Agile": + - Our highest priority is to satisfy the customer through early and continuous delivery of valuable software. + - Welcome changing requirements, even late in development. Agile processes harness change for the customer's competitive advantage. + - Deliver working software frequently, from a couple of weeks to a couple of months, with a preference to the shorter timescale. + - Business people and developers must work together daily throughout the project. + - Build projects around motivated individuals. Give them the environment and support they need, and trust them to get the job done. + - The most efficient and effective method of conveying information to and within a development team is face-to-face conversation. + - Working software is the primary measure of progress. + - Agile processes promote sustainable development. The sponsors, developers, and users should be able to maintain a constant pace indefinitely. + - Continuous attention to technical excellence and good design enhances agility. + - Simplicity--the art of maximizing the amount of work not done--is essential. + - The best architectures, requirements, and designs emerge from self-organizing teams. + - At regular intervals, the team reflects on how to become more effective, then tunes and adjusts its behavior accordingly. + + + +- BEST PRACTICES + - SECURITY (https://docs.microsoft.com/en-us/aspnet/core/security/) + - Enforce SSL: https://docs.microsoft.com/en-us/aspnet/core/security/enforcing-ssl + - PERFORMANCE + - Don't use session state at all if possible. Better to reload a session based on JWT userID from persistent storage. + - LEAST PRIVILEGE: DO NOT REQUIRE ROOT ACCESS Code everything for least privilege possible. The principle of “least privilege” mandates that a process should have the lowest level of privilege needed to accomplish its task + - CONFIGURATION: have as few sources of configuration as possible, ideally in one place or one copy replicated. + - PASSWORDS: passwords to production databases should be kept separate from any other configuration files. They should especially be kept out of the installation directory for the software + - SECRETS: should be using Environment variables in dev and production. MS says in dev do differently but that is not ideal for consistency and testing + - We do not want to have to set multiple configs in multiple locations and hunt around or worse yet have it in code. + - Track configuration changes somewhere for analysis when shit goes south. If a user changed a config need to know when and what was changed. + - Configuration should be highly protected, it contains database passwords and other important stuff + - Never keep the configuration file in the application folder, it will be overwritten on install or restore and if hacked it may be available to the hacker compromising other things + - Name config properties according to their function not their nature. Don't use Hostname, use AuthenticationServer + - A UI might be helpful here, one that can also track old versions of config for reference so user can revert or look back. + + - A database failure should not bring down the webapi server, it should be able to handle that gracefully with helpful diagnostic information + - Need stress and failure testing early and often, i.e. kill database server - what happens? etc + - Need longevity testing, have the system keep testing in the background continuously for a week or more with high adn low volume transaction testing + - SMALL QUERIES: Favor smaller simpler multiple queries through EF rather than trying to get a whole large graph with many joins at once, i.e. build up the object graph from several small queries rather than one huge one. + - (this is my own idea based off issues with things in Ayanova leading to enormous SQL + - Tailor small objects that satisfy what would take a much heavier query to satisfy UI requirements, i.e. a tailored customer list for a specific need would be better than loading all the customer data for the list + - ISOLATE reporting and history / audit log functionality from transactional functionality + - we don't want to use reporting objects in transactions as they are often ad-hoc slower to query, not as streamlined + - Transactions are more important than reporting, don't let reporting hurt transactions + - Consider a separate database for storing anything not required to process transactions like history, audit, INACTIVE objects that are archived, some reporting etc + - PURGING "A rigorous regimen of data purging is vital to the long-term stability and performance of your system." + - a second long term storage db can be used for purged data to keep it out of the active transaction data. + - Transaction code should be vanilla ORM sql issued, avoid hand crafted sql in business transaction code as it makes db tuning much harder + - RESOURCE POOLING is critical + - Be very careful with it + - Do not allow callers to block forever. Make sure that any checkout call has a timeout and that the caller knows what to do when it doesn’t get a connection back + - Undersized resource pools lead to contention and increased latency. This defeats the purpose of pooling the connections in the first place. Monitor calls to the connection pools to see how long your threads are waiting to check out connections. + - CACHING + - Needs a memory limit + - Monitor hit rates for the cached items to see whether most items are being used from cache + - avoid caching things that are cheap to generate + - Seldom-used, tiny, or inexpensive objects aren’t worth caching + - PRECOMPUTE anything large and relatively static that changes infrequently so it isn't dynamically generated every time + - NETWORK (at data center level, these might not apply but making note here) + - good network design for the data center partitions the backup traffic onto its own network segment + - Have a separate network for administration purposes (like we do with softlayer) + - This is actually very important for security and peformance, a hacker of the public interface can't access admin functionality if it's bound to another NIC / network + - App needs to be configurable which network interface is used for which part so that it's not listening on *all* networks exposing danger to the private network + - SLA Even if we never have a service level agreement with our users we should implement it internally so we know if we are living up to it + - Good section in the "Release it" book about this, but for now + - Have some metrics to watch + - Have a service that does synthetic transactions to monitor the live service and log issues. + - SLA should be concerned with specific features not as a whole because some functionality is more important than others and can have radically different possible SLA because it may or may not rely on 3rd parties. + - AN SLA can only ever be as good as the poorest SLA of anything our service relies on. If an integral component has no SLA then we can't have one either. + - LOAD BALANCING: Need it in hosting scenario but it relies on the underlying architecture so this is more in the area of the CONTAINERIZATION Research + - DESIGN FOR FAILURE MODES UP FRONT: failures will happen, need to control what happens when parts fail properly + - One way to prepare for every possible failure is to look at every external call, every I/O, every use of resources, and every expected outcome and ask, “What are all the ways this can go wrong?” + + - Mock client: if hosting, need an external (not co-located) automated mock client that can detect if the system is down + - TIMEOUTS - Always use Timeouts for external resources like database connections, other remote servers network comms etc + - Instead of handling timeouts all over the place for similar ops, abstract it into an object (i.e.QueryObject) that has the timeout code in it + - Use a generic Gateway to provide the template for connection handling, error handling, query execution, and result processing. + - Timeouts have natural synergy with circuit breakers. A circuit breaker can tabulate timeouts, tripping to the “off” state if too many occur. + - Fail fast: when a resource times out send an error response immediately and drop that transaction + - Fail Fast applies to incoming requests, whereas the Timeouts pattern applies primarily to outbound requests. They’re two sides of the same coin. + - DONT JUST TRY A TRANSACTION: Check resource availability at the start of a transaction (check with circuit breakers what their state is) and fail fast if not able to processing + - Do basic user input validation even before you reserve resources. Don’t bother checking out a database connection, fetching domain objects, populating them, and calling validate( ) just to find out that a required parameter wasn’t entered. + + - CIRCUIT BREAKERS + - Coupled with timeouts usually for external resources but could also be used for internal critical code + - Very useful to be able to trip them on demand from an OPERATIONS point of view or reset them on demand + - if a call fails increment a count, if it passes threshold immediately fast fail and stop making that call and start a timeout + - After fail timeout then half open and try the call again if it passes then close the circuit breaker and go back to normal + - If a half open fails again then start the fail timeout again + - These are critical incidents to report + - If circuit breaker is open then it should fast fail message back to it's caller indicating the fault + - Popping a Circuit Breaker always indicates there is a serious problem. It should be visible to operations. It should be reported,recorded, trended, and correlated. + - For a website using service-oriented architectures, “fast enough” is probably anything less than 250 milliseconds. + - Protect against unbounded result sets (i.e. sql query suddenly returning millions of rows when only a few expected [USE LIMIT CLAUSE ALWAYS]) + - DO not allow unbounded result sets to be returned by our api, always enforce a built in limit per transaction with paging if no limit is specified + - this way a user can't request unlimited data in one call + - Also if due to something unexpected a ton of records are created in a table this will prevent a crash from sending all that data back + - TESTING + - REPLICATE PRODUCTION LOADS EARLY IN TESTING: an hour or two of development time spent creating a data generator will pay off many times over + - MULTIPLE SERVERS: if a configuration requires multiple servers in production, be sure to test it that way. + - using Virtual Machines if necessary. + - If testing on one machine what would normally run on multiple it's easy to miss something vital + - FIREWALLS: enable a full firewall on a testing machine and then darefully document any ports that need to be opened as this will be needed for production / installation + - STARTUP AND SHUTDOWN + - Build a clean startup sequence that verifies everything before flipping a switch to let users in (preflight check) + - Don't accept connections until startup is complete + - Don't just startup and then exit if PFC fails, it should be up and running to be interrogated by administrator + - Clean shutdown: don't just hard shutdown, have a mechanism for each module to complete it's work but not accept new work until all transactions are completed + - Timeout the shutdown so if something hangs it can't stop the whole thing from being shut down. + - ADMINISTRATION + - Ability to set entire API to read only mode both on demand (control panel) and in code (for backup process) + - Simple html based admin is ok but command line is better because it can be automated / accessed over a remote shell easily. + - Don't have a fancy native app gui admin because it will piss off administrators and be hard to use over remote access + - Ideally a simple html for regular users and a command line one for power users. + - Try to make every admin function scriptable from the command line + - "Jumphost": a single machine, very tightly secured, that is allowed to connect via SSH to the production servers + - The ability to restart components, instead of entire servers, is a key concept of recovery-oriented computing + - OPS TRANSPARENCY / DASHBOARD + - This is important and needs to be in there just as much as the rest + - Think of a dashboard that can be seen at a glance or left up all day on a screen in a "command center" + - Should show real time snapshot but also scheduled daily events, whether they succeeded or not, i.e. notifications being sent out etc + - transparency: historical trending, predictive forecasting, present status, and instantaneous behavior + - Log to "ops" database "OpsDB" See page 300 of Release IT for more guideance on this. + - Client side api to feed data to ops db + - This is important, see the Release It book page 271 for some guidance on what to track + - For the most utility, the dashboard should be able to present different facets of the overall system to different users. An engineer in operations probably cares first about the component-level view. A developer is more likely to want an application-centric view, whereas a business sponsor probably wants a view rolled up to the feature or business process level. + - COLOR CODING: + - *Green* All of the following must be true: + - All expected events have occurred. + - No abnormal events have occurred. + - All metrics are nominal. + - All states are fully operational. + - *Yellow* At least one of the following is true: + - An expected event has not occurred. + - At least one abnormal event, with a medium severity, + has occurred. + - One or more parameters is above or below nominal. + - A noncritical state is not fully operational. (For example, + a circuit breaker has cut off a noncritical feature.) + - *Red* At least one of the following is true: + - A required event has not occurred. + - At least one abnormal event, with high severity, has + occurred. + - One or more parameters is far above or below nominal. + - A critical state is not at its expected value. (For example, + “accepting requests” is false when it should be true.) + - LOGGING + - Always allow OPS to set the location of the log file + - Use a logging framework, don't roll one. (LOG4NET?) + - Log files are human readable so they constitute a human computer interface and should be designed accordingly + - Clear, accurate and actionable information + - columnar space padded, can be read and scanned quickly by humans and also read by software: + - [datetime] errornumber location/source severity message + - Messages should include some kind of transaction id to trace the steps of a transaction if appropriate (user id, session id, arbitrary ID generated on first step of transaction etc) + - Design with purging / pruning log files in mind up front + - Don't log to a resource used by the production system (i.e. don't log in the same database as the app is using, don't log to the same disk or volume) + - Always use a rolling log format, don't just keep appending. + - Do NOT deploy with full debug logs enabled, it's too much noise to spot problems (see AyaNova current log for that) + - Ensure a ERROR message is relevant to OPS, not just a business logic issue. It should be something that needs doing something about to be error level. + - ** Use short message codes / code numbers so users can convey them easily instead of the long text message!!! + - CATALOG OF MESSAGES build a catalog of all the messages that could appear in the log file is hepful to end users + - MONITORING SYSTEMS + - Logging of severe errors to OS application log can be used to integrate to automatic monitoring systems so it should be an option + - Page 297 of Release It has some idea of what to expose and how to expose it. + - ADAPTABILITY / CODING DESIGN DECISIONS / FUTUREPROOF + - VERSIONING + - Static assets should be in a version folder right off the bat, i.e. wwwRoot/css/v1/app.css, wwwRoot/js/lib/v1/jqueryxx.js or wwwRoot/js/templates/v1/ + - I think naming them similar to the api endpoint versioning is a good idea, i.e "v1" or "v2.1" etc. + - this way they can still be served up to old clients without breaking new ones + - Need the flexibility of having different version numbers at the backend and frontend. I.E. can refer to AyaNova backend v8.1 and front end v8.3 but keep it in the family of 8.x? + - ?? Or maybe the backend is just an incrementing number like a schema update, could be 1000 for all it matters? + - ?? Not sure how to handle the index page, maybe it needs to be version agnostic and in turn call another page or something, + - maybe Index.html with menu to select indexV2.html or indexV2.3.html + - "A new version is available, switch to version 8.5?" user selects and they book mark to that new version indexv8.5.html? + - Database versioning (this one is trickiest of all, can't remove old objects until the api is unsupported, but they might need to change, will require creative solutions) + - Select * is bad with reversioning, instead selecting exact columns is safer and MORE FUTUREPROOF + - Can't drop old columns or set IS NOT NULL on some if they changed that way until after the new release is fully adopted and the old can be removed. + - Refactoring + - Constantly improving the design of existing code + - Only possible with unit testing + - Test driven development: write the test first then write the code to pass the test + - Write just enough code to make the test pass and not one line more (YAGNI), once the test passes you can refactor all you want as long as the test passes + - Mocks are good because they immediately cause the object under test to be "re-used", once in production and once in testing with a mock object, so reuse is tested as well. + - Dependency injection + - components should interact through interfaces and shouldn’t directly instantiate each other. + - Instead, some other agency should “wire up” the application out of loosely coupled components + - The container wires components together at runtime based on a configuration file or application definition + - Encourages loose coupling + - Helps with testing + - Defining and using interfaces is the main key to successfully achieving flexibility with dependency injection + - Objects collaborating through interfaces can have either endpoint swapped out without noticing. + - That swap can replace the existing endpoint with new functionality, or the substitute can be a mock object used for unit testing. + - Dependency injection using interfaces preserves your ability to make localized changes + - https://joonasw.net/view/aspnet-core-di-deep-dive + - https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection + - http://deviq.com/strategy-design-pattern/ + - http://deviq.com/separation-of-concerns/ + + - SEPARATION OF CONCERNS + - Presentation layer + - The Presentation Layer should include all components and processes exclusively related to the visual display needs of an application, and should exclude all other components and processes + - Service interface layer + - + - Business layer + - The primary goal of the Business Layer is to encapsulate the core business concerns of an application exclusive of how data and behavior is exposed, or how data is specifically obtained. The Business Layer should include all components and processes exclusively related to the business domain of the application, and should exclude all other components and processes. + - Object model + - Business logic + - Workflow + - Resource access layer + - The goal of the Resource Access Layer is to provide a layer of abstraction around the details specific to data access. + - The Resource Access Layer should include all components and processes exclusively related to accessing data external to the system, and should exclude all other components and processes + - FIPS + - Don't use managed encryption if want to support FIPS + + +TOOLING +=-=-=-= +NO PROPRIETARY OR COMMERCIAL COMPONENTS OR TOOLS WHEREVER POSSIBLE +Need to automate the fuck out of anything that can be automated. + Do this early on so time is saved right from the start. + + + +NAMING +=-=-=- + +.net Namespace: +COMPANY.PRODUCT.AREA (server) +GZTW.AyaNova.whatever-whatever + + +Files, routes, urls etc: +Use lowercase entirely everywhere, do not use uppercase, this avoids future confusion all around. +No spaces in names, this avoids having to use quotes in paths etc +Use spinal (kebab) delimiter, i.e.: coding-standards.txt +Here is some REST api guidelines to naming: +https://github.com/Microsoft/api-guidelines/blob/vNext/Guidelines.md#16-naming-guidelines + +CSS: +BEM naming - http://getbem.com/ + + + + + +#TESTING +- THINGS TO TEST + - Concurrency exceptions with each db type as it could be an issue +- Coding should go hand in hand with testing, don't write anything that can't be tested immediately +- Write a data generator that goes hand in hand with testing, need large, realistic dataset generatable on demand to support testing +- Unit tests where useful but a main focus on integration tests, need to be able to hit one button and be certain a build is passing +- Going to need to test all architecture levels early and continuously. I.e. in a docker container, stand-alone, different DB types etc +- Test should include exported data from v7 regularly. + + +```A second, more subtle effect is produced through consistent unit testing. +You should never call an object “reusable” until it has been reused. +When an object is subjected to unit testing, it is immediately used in +two contexts: the production code and the unit test itself. This forces +the object under test to be more reusable. Testing the object means you +will need to supply stubs or mocks in place of real objects. That means +the object must expose its dependencies as properties, thereby making +them available for dependency injection in the production code. When +an object requires extensive configuration in its external context (like +the previously mentioned Customer object), it becomes difficult to unit +test. One common—and unfortunate—response is to stop unit testing +such objects. A better response is to reduce the amount of external context required. +In the example of the Customer domain object, extracting +its persistence responsibilities reduces the amount of external context +you have to supply. This makes it easier to unit test and also reduces +the size of Customer’s crystal—thereby making Customer itself more malleable. +The cumulative effect of many such small changes is profound.``` + + +DOCUMENTATION +=-=-=-=-=-=-= +All documentation will be primarily in Markdown format following the Commonmark spec http://commonmark.org/help/. +See tooling doc for how to use commonmark markdown +If other formats are required they will be generated *from* the markdown. +The api should be self documenting so docs can be generated and api routes can provide information and examples + i.e. while coding write the docs for each route / method etc. +IF we want to do a web sequence diagram there is a handy tool: + - https://www.websequencediagrams.com/ + + + + +ERROR MESSAGES +The 4 H’s of Error Messages + +Human +Helpful +Humorous +Humble + + diff --git a/devdocs/features.txt b/devdocs/features.txt new file mode 100644 index 00000000..03f8aef9 --- /dev/null +++ b/devdocs/features.txt @@ -0,0 +1,228 @@ +# RAVEN FEATURES / CHANGES FROM AYANOVA 7.x + +* Items with an asterisk are completely new + +## FOUNDATIONAL ITEMS / NFR +( Things that need to exist right off the start) + +### SECURITY / AUTHENTICATION + +- SECURITY GROUPS / RIGHTS + - https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/1809 + - Moving to roles + + +- AUTHENTICATION + +### COMMON BUSINESS OBJECT PROPERTIES +- *Tags +- Regions +- Attachments and docs +- Wiki pages +- Custom fields +- Common actions quantified and identified +- Event log (actions / events enum) +- Notification (notifiable interface?) +- Support log (feature usage, crashes, ui element used) +- BIZ object switch / circuit breaker +- ID number generator for PO's workorders, quotes, pms etc. Unique number for each type. + + +### SHELL / MENU + + +## FUNCTIONAL REQUIREMENTS + +### FEATURES +- MRU - shell +- PLUGINS +- WIKI PAGE +- LOGOUT +- SUBGRIDS + - CLIENT GROUPS + - DISPATCH ZONES + - PART ASSEMBLIES + - PART CATEGORIES + - PARTS WAREHOUSES + - PRIORITIES + - RATES + - TAX CODES + - UNIT MODEL CATEGORIES + - UNITS OF MEASURE + - UNIT SERVICE TYPES + - USER CERTIFICATIONS + - USER SKILLS + - WORKORDER CATEGORIES + - WORKORDER ITEM TYPES + - WORKORDER STATUSES - BIG NEW FEATURES FOR THIS ONE +- QUICK OPEN WORKORDER / QUOTE / PM BY NUMBER +- HELP + - CONTENTS F1 (goes to manual online) + - TECHNICAL SUPPORT (goes to the forum) + - CHECK FOR UPDATES (popup dialog) + - PURCHASE LICENSES (goes to license FAQ page, not purchase page) + - ABOUT AYANOVA (shows some support info as well as version) + - LICENSE (enter / view license) +- EXIT (closes AyaNova, weird that it's there and help isn't last) +- CUSTOMIZE TEXT (administrator only) + + +### DASHBOARD + + +### SERVICE WORKORDERS +- SERVICE WORKORDER +- SERVICE WORKORDER TEMPLATES + +### QUOTES +- QUOTE WORKORDER +- QUOTE TEMPLATES + +### PREVENTIVE MAINTENANCE +- PM WORKORDER +- PM TEMPLATES + + +### SCHEDULE + + +### INVENTORY +- PARTS +- PURCHASE ORDERS + - PO ITEMS +- PURCHASE ORDER RECEIPT + - PO RECEIPT ITEMS +- ADJUSTMENTS + - ADJUSTMENT ITEMS +- PART INVENTORY +- PART REQUESTS + + +### CLIENTS +- CLIENTS +- HEADOFFICE +- CONTRACTS +- PROJECTS +- CUSTOMER SERVICE REQUESTS + + +### UNITS +- UNITS +- UNIT MODELS +- LOAN ITEMS + +### VENDORS +- VENDORS (only item, nothing else here, hmmmm.....) + + +### USER PANE +- MEMOS +- NOTIFICATION SUBSCRIPTIONS +- NOTIFICATION DELIVERIES (user or all if manager account) +- WIKI PAGE + +### SEARCH +- has no sub items at all (will be deprecated and moved into every page at top) + +### ADMINISTRATION + +- *ONBOARDING / GUIDED SETUP + +- GLOBAL SETTINGS +- REGIONS +- SECURITY GROUPS +- USERS +- CUSTOM FIELDS DESIGN +- LOCALIZED TEXT DESIGN +- NOTIFICATION DELIVERIES +- REPORT TEMPLATES +- FILES IN DATABASE +- SCHEDULE MARKERS + + +### PLUGINS / ADD-ONS +- AyaScript +- DUMP +- ExportToExcel +- ImportExportCSV +- Merger +- OutlookSchedule +- PTI +- QBI +- QBOI +- QuickNotification +- XTools +- OL + + + + + + + + + + +Dec 29th 2017 new workorder structure: + +Users will use templates heavily to get predefined workorder things pre-added so they can just enter what they need. +Create new workorder, templates are offered with that option automatically and maybe some built in ones to get people going. + +Instead of the todo and completed sections, each line has a completed or not status and it displays differently +so can still see at a glance what is done or not done. Items group within category by completed status. + +So items with quantities are not "completed" until they have an entry in their Quantity field. Suggested quantities only are incomplete. +Items without Quantities have a "completed" or "serviced" check-box or some alternative that still gives equivalent to completed. + + +Instead of Service and materials is the above features, however there could still be a similar view for certain roles. + +Can select charge to customer in same record if completed (or added records with real quantities). +By default charges to customer automatically, maybe a setting though? + + Invoice and payments etc still to be determined +_______________________________________ +[WO HEADER BLOCK] + - header items +[TODOS BLOCK] + [TODO1 HEADER BLOCK] + + TODO1 ITEMS + - Scheduled techs [no completed status?, does have a checkin feature maybe checkin triggers reveal completed as checkout equivalent?] + - Parts [suggested qty and real qty] + - Parts requested / on order ["completed" when all received] + - Unit(s) [serviced checkbox] + - Tasks [already has completed checkbox] + - Outside service shipping [suggested and real] + - Outside service repairs [suggested and real] + - Loan item [suggested qty and real qty] + - Custom fields [does this need some kind of completed?] + - Travel [suggested and real qty fields] + - Labor [suggested and real qty fields] + - Expenses [suggested and real qty fields] + + + TODO2 HEADER + TODO ITEMS + DONE ITEMS + + + ..... + + +INVOICE - this is a view that shows bill to customer items, same stuff different view +PAYMENTS +PROFIT AND LOSS +_________________________________________ + + + + + + + + + + + + diff --git a/devdocs/project-goals.txt b/devdocs/project-goals.txt new file mode 100644 index 00000000..08427019 --- /dev/null +++ b/devdocs/project-goals.txt @@ -0,0 +1,37 @@ +This case is for overall goals of what or how AyaNova 8 should be different / improved +____________________________________________________________________________________________ + +- FIRST AND FOREMOST: has to be something I can sell and support alone with minimal effort + if any part of the process requires me to spend time regularly on it then that needs to be eliminated or automated. +- DIRECT PORT of AyaNova 7.x, try to keep as similar as possible but improving where necessary + - It would be crazy to try to re-invent the wheel on this +- Easier to upgrade for the customer - no hassle updates + - Should be a black box that only relies on the db and the configuration to be updated. +- SIMPLER FOR A USER TO UNDERSTAND + - Simplify the structure, anything confusing or redundant eliminate + - Things like TAGS which eliminate the need for a bunch of different separate objects + - No need to learn about a separate "Dispatch Zone" object, just use a tag for that and so we can provide examples of how to tag and how to use tags without + having to teach or support a bunch of disparate features. + +- Modern technology as open source as possible (no proprietary code if possible) +- More quickly add new features and not have to make huge monolithic updates but can do a quick feature add +- Scale from standalone single user to containerized service capable of being hosted for thousands +- Fully secure to modern standards that are appropriate +- Cleaner interface, fewer separate objects, more use of tags instead of discrete categorization type objects +- Easy to use modern API, self documented for users with example snippets +- Easy to learn with guided training +- Easier to support for us and the customer +- Easier to install for the customer +- Easier to sell and manage sales end of it +- Easier to maintain for the customer (backups, upgrades etc) +- Better looking, modern, clean more focused interface that is easier to enter data into quickly +- Imports as much of AyaNova 7.x data as possible automatically +- Easier to trial for test users +- Something we can offer to host with as minimal hassle to us as possible +- Operating system agnostic +- Responsive interface for a variety of screen sizes (or at least wide and phone) +- Automated build of api docs that can be accessed from the api itself +- Automated or easier generation of manual / MARKDOWN +- Support the latest browser and the one before it of every major browser like gitlab does: + - Supported web browsers, We support the current and the previous major release of Firefox, Chrome/Chromium, Safari and Microsoft browsers (Microsoft Edge and Internet Explorer 11). + - Each time a new browser version is released, we begin supporting that version and stop supporting the third most recent version. \ No newline at end of file diff --git a/devdocs/research.txt b/devdocs/research.txt new file mode 100644 index 00000000..83c9e341 --- /dev/null +++ b/devdocs/research.txt @@ -0,0 +1,244 @@ +# Research required +“Do the simplest thing that will work.” + + +# BACK END / ARCHITECTURE + + +## DOCKER FINDINGS + - Need a shared docker network between all containers, I was assuming could just redirect to localhost but didn't realize docker network is internal to docker + - this helped https://stackoverflow.com/questions/39202964/how-to-reach-another-container-from-a-dockerised-nginx?rq=1 + - first created a defined external network sudo docker network create docker-network + - HOWEVER - I think docker has a default network so this external network probably isn't necessary, ??? MORE RESEARCH REQUIRED HERE + - Then in the docker compose files of all involved had to put at the bottom one network statement (none in the service sections above) + - networks: + docker-network: + driver: bridge + + - need to run Nginx in front of ayanova for easiest ssl cert handling etc + - Very good reference here: https://gist.github.com/soheilhy/8b94347ff8336d971ad0 + - Nginx needs to see ayanova via the docker host name which is the container name + - So I had to point it like this: + proxy_pass http://ayanova:7575; + - Where ayanova is the image name in the docker compose file for starting AyaNova server + + + + + + + + +## DEFAULT PORTS (unassigned) http://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.txt + - Some available ports not specifically assigned that I like the look of: + - 4048, 4144, 4194, 4195, 4196, 4198, 4317, 4318, 4319, 4332, 4337-4339 , 4363-4365, 4366, 4424, 4434-4440, 4748, 5076-5077, 5551-5552,5994-5998 + - 7575-7587, 8084-8085 + - 7575 (RAVEN IS USING THIS) + - 5995? + - 4424? + - 8084? + +## ?? LICENSING + - ??how to functionally license RAVEN + - ??how to protect the keys + +## ?? How am I going to expose the api for developers + - Only through the rest interface? + - Will there be plugins at the backend code level in any of those layers? + - OUR STUFF VS END USERS STUFF + - What existing plugins did we make and what would they need from Raven to be replicated? + - how will this affect api decisions? + - What can end users be allowed to do and how and at what layer. + + +## ?? FUNCTIONAL REQUIREMENTS /FEATURES + +Look at AyaNova existing code, +A) what worked well +B) what was repetitious and should be automated +C) what had ongoing problems + +Will help greatly with design + +## TAGS + - Wait to choose implementation type until basic design is decided upon as it will affect method use internally + - This is actually a big adn tricky topic, here are some resources found while researching: + - http://tagging.pui.ch/post/37027745720/tags-database-schemas + - https://stackoverflow.com/questions/172648/is-there-an-agreed-ideal-schema-for-tagging + - https://dba.stackexchange.com/a/35795 <---this is what I'm leaning towards + - Leaning towards a single tags table with word and id (and maybe other properties like color, last used etc) + - Each taggable other table has a map of it's own to the tags, so i.e.[TAGS: id, text] [CUSTOMERTAGS: customerID, tagId], [WORKORDERTAGS: WOID, tagID] + - This will require a bit of complexity to keep it pruned though, i.e. tags will get orphaned when no one is using them so a routine will periodically need to clean them out. + - This may be a better way to handle full text indexing as well with a search table for each object table. Kind of balloons the table count though, but who cares really, does that matter? + - An alternative is exactly like how I do full text search in AyaNova now with a generic foreign key, upside is cleaner db structure, but downside is ensuring cleanup and maint. + - If parent object is deleted must go in and remove any linked tags as well. + - Upside is it's easily searchable for all incidents of a specific tag over all taggable types, but is this really necessary? It can still be done with separate queries. + + +** SEPARATE FOR *MVP* PRIORITY ONE IS MUST HAVE BEFORE RELEASE 8.0 / PRIORITY 2 OR LOWER IS NICE TO HAVE ** +- go over all rockfish AyaNova related cases and request for features, into the hopper. + - Move stuff to v8 if going forward, otherwise drop it's priority to lowest in AyaNova 7.x and any related project cases + - Move any items that just have to be done now for v7 into high priority AyaNova 7.x track. + - ?? What are the commonalities of things required in the UI to come from the backend based on current AyaNova + - Name ID lists? + - list factories + - report factories + - fetch by name / id + - Email sending and processing + + + + +## ?? CONCURRENCY ISSUES + - In the ef core book the author mentions getting around a concurrency issue by making it a non issue by not even updating the same order but instead appending status changes to another table + - https://livebook.manning.com/#!/book/entity-framework-core-in-action/chapter-8/v-7/171 + - "This design approach meant that I never updated or deleted any order data, which means that concurrent conflicts could not happen. It did make handling a customer change to an order a bit more complicated, but it meant that orders were safe from concurrent conflict issues." + - Microsoft tutorial: https://docs.microsoft.com/en-us/aspnet/core/data/ef-mvc/concurrency + - See what will be critical concurrency in RAVEN (inventory likely), see if can code around it so no concurrency can happen. + - Plan on what is concurrency prone and how to deal with it. + - Maybe split out the concurrent sensitive bits of a workorder (parts) to a different screen, i.e. show read only in workorder adn to update that specific section have to go in and do it like a wizard or something? + +## ?? BACKDOOR / LOST PASSWORD + - How to handle this, current method is horribly susceptable to nefarious purpose + - Maybe requires "physical" access to the server (put a file up or something?) + +## ?? AUTOMATED BACKGROUND PROCESSOR "GENERATOR" + - What will replace "Generator" in RAVEN? + + +## FRONT END + +- VISUAL DESIGN / UX + - - https://www.ventureharbour.com/form-design-best-practices/ + - Make the form designs simple and beautiful, it will directly translate into sales + - Field labels above fields, not beside them (https://research.googleblog.com/2014/07/simple-is-better-making-your-web-forms.html) + - Single column vertical layout for complex forms is definitely best. In wide mode though what to do? + - It's ok to have two inputs on a line together if they are directly related so that they have one single label above them and minimal space between them + - i.e. date and time ([day] [month] [year] [hour][minute] etc) + - But really don't if possible because it's never better than a single field that just "figures out" the parts of the input. + - Always indicate required fields, don't just error if they are not filled out. + - Break big forms into sections with shaded headers or emboldened headers with larger fonts but shaded background is good even with no text to break apart. + - Use single fields for phone numbers or postal codes and process and interpret it through code rather than forcing them to use multiple fields as this causes confusion + - Messages to users that are important and long and required to convey need to *really* stand out to even be read. In their own box with a different background or something alike. + - Put validation errors to the right of the input field, not below it as users can see it better and it requires less cognitive load. + - Multi page forms (like wizards) need to have progress indicators or people will get antsy and bail + - Favor checkboxes or radio buttons over drop downs unless there are more than 6 or so options as they require less cognitive load + - Use placeholders sparingly (light gray prompt inside input) only when there is ambiguity, don't use them for obvious stuff + - Always use a predictive search / autocomplete for any field that requires a selection from a large number of options + - Selectable images are the most compelling engaging selection type for users + - I.E. like a row of radio buttons but a row of images to select from and the choice is the selection by clicking on the image. + - People enjoy clicking on images. + - Use this as much as possible wherever possible. I.E. for things like + - A trial user picking the company type they would like to trial data for + - Input fields should be sized to reflect the amount of data required to be entered in them + - This helps the user understand what is required of them + - ZIP code or house number should be much shorter than an address line for example + - Do not rely on colour alone to communicate + - 1 in 12 men have some degree of colour blindness + - Ensure entire forms can be navigated using the tab key. + - Test the form in low and bright light situations on a mobile device outdoors and desktop + - Enable browser autofill with contextual tags + - Not exactly sure what this means in practice but it sounds good + - Use visual cues and icons, brains process visual cues way faster than text alone + - Don't ask for a password twice, use a eyeball icon to reveal text instead like keepass does + - Mobile should be at least 16px high text / desktop can be 14px + + +- FRONT END FRAMEWORK + - https://stackshare.io/stacks (interesting tool to see what other companies use in their stacks) + - https://semantic-ui.com/ + - Vue.js https://vuejs.org/v2/guide/comparison.html + - JS IN HTML via directives (kind of like Angular) + - State management library if required: https://github.com/vuejs/vuex + - Similar to redux but different + - Module for VS Code!! https://vuejs.github.io/vetur/ + + - PROS: + - GUIDE: https://vuejs.org/v2/guide/ + - Simpler to code than React "Vue has the most gentle learning curve of all js frameworks I have tried" + - Single file components, all aspects of a component are in the same file (css, js, html) + - UI LIBRARY: http://element.eleme.io/#/en-US + - Everyone keeps claiming it's more productive + - Comes with a databinding and MVC model built in + - CLI project generator + - Has official routing solution and state management solutions + - They are starting to work on a native library like react native (not there yet though and maybe I don't care about that so much) + - Getting things done over "purity" + - http://pixeljets.com/blog/why-we-chose-vuejs-over-react/ + - Working with html forms is a breeze in Vue. This is where two-way binding shines. + - Vue is much simpler than AngularJS, both in terms of API and design. Learning enough to build non-trivial applications typically takes less than a day, which is not true for AngularJS + - CONS: + - Less answers on stackoverflow than React (for example) + - Vue on the other hand only supports IE9+ (not sure if this is a con or not) + - Doesn't have a major corporation behind it like React or Angular + - (2016)runtime errors in templates are still a weak point of Vue - exception stacktraces in a lot of times are not useful and are leading into Vue.js internal methods + - React.js + - HTML in JS + - PROS: + - Very widely used, tons of resources + - Works with huge apps + - Can be compiled to native apps (somehow, though Vue is working on it) + - CONS: + - Requires a good grasp of javascript + - Harder to code + - Requires a *TON* of add-on libraries as it only deals with the view part + - "PURITY" over getting things DONE + - Very nitpicky and fuckery prone just to do things the official way, slower to get business objectives accomplished + - Ember + - PROS: + - Requires less knowledge of javascript + - Comprehensive includes all bits + - Suited to small teams + - CONS: + - Angular + - PROS: + - Google and Microsoft supported + - Supposed to be good for someone coming from C# (typescript) + - CONS: + - Requires a *lot* of learning to use + - Typescript + - Angular directives and ways of doing things which are peculiar to it + - Possibly slower than others to render + + +- ARCHITECTURE / TECHNOLOGY STACK + - Electron hosted desktop app like vs code or Slack: + - What is the advantage of Electron hosted app vs just a plain html 5 app? (nothing) + - https://slack.com/downloads/windows + - https://blog.bridge.net/widgetoko-a-node-js-and-electron-application-written-in-c-1a2be480e4f9 + + + +- PERFORMANCE: Do not send one byte extra that is not needed at the client + - Not even a single space, + - especially not extra libraries or unminified code or cases + - ICONS and webfonts should have only what is needed, nothing more. + - ??USING A CDN?? +- VERSIONING STATIC RESOURCES + - Use Gulp, process with hash *in filename* not as a query string (better supported by intermediate caching or proxy servers) + - https://docs.microsoft.com/en-us/aspnet/core/client-side/using-gulp + - https://code.visualstudio.com/docs/editor/tasks + - https://hackernoon.com/how-to-automate-all-the-things-with-gulp-b21a3fc96885 + - https://github.com/sindresorhus/gulp-rev + - https://github.com/jamesknelson/gulp-rev-replace +- FORM VALIDATION: + - https://developer.mozilla.org/en-US/docs/Learn/HTML/Forms/Form_validation +- ?? framework for complex UI required or plain old javascript? (imagine a workorder or PO) + - Need a lot of shit on forms, maybe a framework is the way to go: + - VALIDATION / DIRTY CHECK + - AJAX FEEDBACK + - ?? + + +- ?? Funky graphs +- ?? Markdown + - Generate html pages from Markdown docs: https://github.com/Knagis/CommonMark.NET + - Markdown UI editor I haven't evaluated yet: https://github.com/nhnent/tui.editor + + +- ?? TESTING + - ??Automated UI testing in browser. + - To catch browser changes that break functionality. + - Get a quick tool overview. + - Also can it use diff. browsers and devices? + diff --git a/devdocs/solutions.txt b/devdocs/solutions.txt new file mode 100644 index 00000000..1c2db595 --- /dev/null +++ b/devdocs/solutions.txt @@ -0,0 +1,231 @@ +# Raven solutions +*Solutions, tools and techniques to accomplish goals from research* + +“Do the simplest thing that will work.” + + + +## Middleware docs + - https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?tabs=aspnetcore2x + +## API FEATURES + - Ability to set whole api to read only mode by administration or by code like backup routines + +### Biz object TAGGER / MARKER INTERFACE or ATTRIBUTES (ITaggable, IAttachable etc etc) + - Apparently should use attribute not interfaces: https://stackoverflow.com/questions/2086451/compelling-reasons-to-use-marker-interfaces-instead-of-attributes + - https://docs.microsoft.com/en-us/dotnet/standard/attributes/writing-custom-attributes + - But if I do use interfaces or need to work with them in future then: + - Map all objects on boot: https://garywoodfine.com/get-c-classes-implementing-interface/ + - It's called a tagging interface: https://en.wikipedia.org/wiki/Marker_interface_pattern + - https://stackoverflow.com/questions/15138924/c-sharp-how-to-determine-if-a-type-implements-a-given-interface + + + +## AUTOMATIC JOB SCHEDULER / RUNNER + - jobs in background required for auto backup, mailing, notifications + - https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/background-tasks-with-ihostedservice + + - Probably don't need a full fledged scheduler because the above should work, but just in case here are some: + - Fluent Scheduler looks simpler than hangfire, supports .net core + - https://github.com/fluentscheduler/FluentScheduler + - Chroniton looks super basic + - https://github.com/leosperry/Chroniton/wiki/Example-ASP.NET-Core + + +## VALIDATION / BUSINESS RULES (FRONT AND BACK) + - To run in both places looks like JSON schema is the way to go, it can be stored independent and validated at both ends + - It's a mature standard and is platform agnostic + - Tutorial from AJV guy: https://code.tutsplus.com/tutorials/validating-data-with-json-schema-part-1--cms-25343 + - https://github.com/RSuter/NJsonSchema This generates and validates schema in .net world and is open source MIT license + - https://github.com/epoberezkin/ajv This is seemingly the gold standard for javascript based + - Lesser things looked at: + - https://github.com/cachecontrol/json-rules-engine 128 stars might be adaptable + - https://github.com/rsamec/business-rules-engine //this one is for javascript and kind of limited but gives some good ideas + + +## RESOURCE ACCESS LAYER (DATABASE) + - DATABASE + - Support Postgresql only out of the box, consider other db's later + - CONCURRENCY IS BUILT INTO EFCORE: https://docs.microsoft.com/en-us/ef/core/saving/concurrency + - Transactions are also built in by default as the save changes is the only point that stuff actually gets written to the db in most cases + - MUST TEST CONCURRENCY AND TRANSACTIONS FAIL + - CONTAINERIZED DB's + - For development, I don't think there's anything better for databases, it beats manual setup, vagrant boxes, and shared development servers by a long shot. I feel that educating everyone on your team in how to use it is well worth the investment. docker-compose makes setting up even a fairly complicated development environment a breeze. + - BACKUP AND RESTORE + - DISCOURSE METHOD + - I like it because it handles various scenarios and results in a nice SQL command file that rebuilds the whole db, not some cryptic binary format + - Can set all api to read only mode, then dumps the db using a db command, then zips it and offers it for download + - Backup process + - pause the sidekiq background process worker + - Can optionally set the api to read only mode (interesting idea) + - dumps the data to a file in sql command format for maximum compatibility (even with other db server types puportedly) + - Archives it + - presents it in the UI for download + - unpause the background worker + - Restore process + - This one is interesting, PGSQL has a "schema" which is a way of partitioning a database to in effect have a separate set of tables in the same db + - They move the "public" production "schema" to a "backup" schema (effectively moving it but keeping it in the db) + - They restore to a separate interim "restore" "schema" then they move all the tables in the restore schema to the production "public" schema (one by one in a loop) + - I guess this way it's reversible if there is an issue but I don't see code to handle any issues + - https://github.com/discourse/discourse/tree/master/lib/backup_restore + - Also seems to have some capacity to send it to an AWS bitbucket or some thing, maybe an online integration with dropbox or other would be nice + + + + +## ARCHITECTURE / NFR + + + + + +### Subdomains pointing to differetn droplets tutorial + - https://www.digitalocean.com/community/tutorials/how-to-set-up-and-test-dns-subdomains-with-digitalocean-s-dns-panel + +### Other stuff +This section contains the architectural plans for all aspects of Raven driven by goals and research +https://en.wikipedia.org/wiki/Non-functional_requirement + + - ARchitecture reference resources: + - https://stackoverflow.com/questions/5049363/difference-between-repository-and-service-layer + + - ARCHITECTURE LAYERS + - CLIENT + - HTML 5 SPA + - WEB API LAYER + - REPOSITORY LAYER + - BUSINESS LAYER (AKA DOMAIN LAYER) + - The Business Layer is the place where all the business/domain logic, i.e. rules that are particular to the problem + that the application has been built to handle, lives. This might be salary calculations, data analysis modelling, + or workflow such as passing a order through different stages. + - I'm using this: https://www.thereformedprogrammer.net/a-library-to-run-your-business-logic-when-using-entity-framework-core/ + - and this is the repo: https://github.com/JonPSmith/EfCore.GenericBizRunner + + - DATA ACCESS LAYER (EF CORE) + - EF CORE multiple database stuff + - Migrations with different providers: https://stackoverflow.com/questions/42819371/ef-core-multiple-migration-sets + - DATABASE + + + + + - .NET CORE DEPLOYMENT + - Basically just get the files on to the system in one case or need .net installed as a pre-requisite then drop the files on + - https://docs.microsoft.com/en-us/dotnet/core/deploying/deploy-with-cli + - https://docs.microsoft.com/en-us/dotnet/core/deploying/ + - KESTREL alone + - Kestrel alone can be used but it won't work if need to share a port with something else and differentiate by host header + - in case of host header issue can run NGinx in front or IIS + - More to come here once we have a testable skeleton project to set up + - STATIC FILE CACHING + - https://andrewlock.net/adding-cache-control-headers-to-static-files-in-asp-net-core/ + + - Asp.net Core 2.0 application stack + - .net core WEBAPI project + - Swagger for documentation + +- REST best practices + - Excellent reference guide here: https://github.com/Microsoft/api-guidelines/blob/vNext/Guidelines.md + - URLS: A good api url: https://api.ayanova.com/v1.0/client/22 + - Keep length under 2000 characters for maximum client compatibility + - concurrency (The ETag response-header field provides the current value of the entity tag for the requested variant. Used with If-Match, If-None-Match and If-Range to implement optimistic concurrency control.) + - JSON property names SHOULD be camelCased. + - There are specific Date, time, Duration, Interval formats that should be used (https://github.com/Microsoft/api-guidelines/blob/vNext/Guidelines.md#113-json-serialization-of-dates-and-times) + - Error response: The error response MUST be a single JSON object. This object MUST have a name/value pair named "error." The value MUST be a JSON object. + This object MUST contain name/value pairs with the names "code" and "message," and it MAY contain name/value pairs with the names "target," "details" and "innererror." + eg: error:{code=1200,message="blah"} error:{code=1200,message="blah",target="eg property name", details="details for programmer", innererror:} + - Versioning: this is an area I need to treat carefully, there are tools to make it easier: + - I will use URL Path segment versioning, i.e. api/v1.0/client/22 + - https://www.hanselman.com/blog/ASPNETCoreRESTfulWebAPIVersioningMadeEasy.aspx + - https://github.com/Microsoft/aspnet-api-versioning/wiki + - https://github.com/Microsoft/aspnet-api-versioning/tree/master/samples/aspnetcore/SwaggerSample + - Push notifications (I.e. on a client record being created or a workorder updated or...?) + - If so there is a segment in the rest doc from microsoft that goes over it in detail + - API THROTTLING / RATE LIMITING + - https://github.com/stefanprodan/AspNetCoreRateLimit + - ASYNC / BUSINESS LAYER + - https://stackoverflow.com/questions/42276149/best-practice-for-using-async-await-in-webapi + - https://stackoverflow.com/questions/41719661/asp-net-core-making-service-asynchronous + - http://www.codemag.com/article/1701061 BUSINESS LAYER STUFF + - https://github.com/RickStrahl/AlbumViewerVNext + +## AUTOMAPPER + - Sounds like a tool I might need, here is a good tutorial: https://dotnetcoretutorials.com/2017/09/23/using-automapper-asp-net-core/ + + + +## TESTING +- TESTING performance / load / resiliency testing + - https://docs.microsoft.com/en-us/aspnet/core/testing/ + - Spend the time to generate realistic production level data in large quantity for testing + - Do not do any integration testing without realistic data + - Data generation for testing could be re-used for trial data generation for customer evaluation purposes + - Test with production sized data !!!! (did not do this properly when doing AyaNova originally) + - TOOLS + - xUnit https://xunit.github.io/ + - Mocking, different test runners here: http://asp.net-hacker.rocks/2017/03/31/unit-testing-with-dotnetcore.html + - GENFU - tries to automatically fill objects which sounds dicey https://github.com/MisterJames/GenFu/ + - https://github.com/bchavez/Bogus This is a .net port of faker.js and as such probably a good choice + - Testing with a docker container (old but interesting) https://devblog.xero.com/getting-started-with-running-unit-tests-in-net-core-with-xunit-and-docker-e92915e4075c +## LOGGING + - Watch out, logging can be a huge performance drain, test with logs on and off to see what and if necessary replace logging class with something faster. + + + + +## REFERENCE RESOURCES + + +- ASP.NET CORE FUNDAMENTALS DOCS + - Everything to do with asp.net core fundamental coding aspects + - https://docs.microsoft.com/en-us/aspnet/core/fundamentals/?tabs=aspnetcore2x + +- AWESOME .NET CORE + - Has a list of solutions for .net core project needs + - https://github.com/thangchung/awesome-dotnet-core + + +- THIS TYPE OF PROJECT REFERENCE CODE + - NICE DIAGRAM OF OUR ARCHITECTURE FOR RAVEN: http://www.dotnetcurry.com/entityframework/1348/ef-core-web-api-crud-operations + - https://github.com/RickStrahl/AlbumViewerVNext + - This is a good one, it uses .net core, runs on multiple platforms, has an angular front end, is from a seasoned practical developer etc etc + - https://github.com/dodyg/practical-aspnetcore + - .net samples and tutorials on official docs page + - https://docs.microsoft.com/en-us/dotnet/samples-and-tutorials/ + - https://samueleresca.net/2017/02/implementing-solid-data-access-layers-using-asp-net-core/ + - AUTOMAPPER: https://github.com/AutoMapper/AutoMapper/wiki/Getting-started + - https://www.infragistics.com/community/blogs/dhananjay_kumar/archive/2016/03/07/how-to-implement-the-repository-pattern-in-asp-net-mvc-application.aspx + + +- ORCHARD + - Almost the perfect reference application, they are doing what I will be doing but for a CMS + - https://github.com/OrchardCMS/OrchardCore + - This samples link actually contains a lot of useful info as well like multi-tenanting stuff etc + - https://github.com/OrchardCMS/OrchardCore.Samples + +- DISCOURSE + - this app is kind of what raven will become in many ways architecturally, + - It's a message board that is modern uses Postgresql, digital ocean docker container, mobile and desktop browser UI + - It's open source so plunder it's source code here: + - https://github.com/discourse/discourse + - https://github.com/discourse/discourse_docker/blob/master/samples/standalone.yml + + + +- DOCKER CONTAINERIZED APP BUILD GUIDE + - This guide has a *lot* of good info in it that is right up my alley: + - https://www.red-gate.com/simple-talk/sysadmin/containerization/overcoming-challenges-microservices-docker-containerisation/?utm_source=simpletalk&utm_medium=pubemail&utm_content=20170926-slota5&utm_term=simpletalkmain + +- FRONT END DASHBOARD + + - Cool dashboard with graphs and shit: https://github.com/tabler/tabler?utm_source=DigitalOcean_Newsletter + +## HELP AND DOCUMENTATION RESOURCES + - ONLINE HELP FOR RAVEN CUSTOMERS + - The Jenkins help page has a really good layout for help with Guided Tour, User Handbook, Resources and Recent Tutorials on left panel + - https://jenkins.io/doc/pipeline/tour/hello-world/ + + +## GRAPHICS / ARTWORK UI FANCIFICATION RESOURCES +- Free for use background image generator that looks really nice and soothing: https://coolbackgrounds.io/ +- Free for any use even without attribution stock photography: https://unsplash.com/ \ No newline at end of file diff --git a/devdocs/specs/admin-settings-business.txt b/devdocs/specs/admin-settings-business.txt new file mode 100644 index 00000000..861bf79b --- /dev/null +++ b/devdocs/specs/admin-settings-business.txt @@ -0,0 +1,17 @@ +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3488 +Was all this stuff below, however, will be different in RAVEN + +### ADMINISTRATION + +- *ONBOARDING / GUIDED SETUP + +- GLOBAL SETTINGS +- REGIONS +- SECURITY GROUPS +- USERS +- CUSTOM FIELDS DESIGN +- LOCALIZED TEXT DESIGN +- NOTIFICATION DELIVERIES +- REPORT TEMPLATES +- FILES IN DATABASE +- SCHEDULE MARKERS \ No newline at end of file diff --git a/devdocs/specs/admin-settings-system-operations.txt b/devdocs/specs/admin-settings-system-operations.txt new file mode 100644 index 00000000..93f840f5 --- /dev/null +++ b/devdocs/specs/admin-settings-system-operations.txt @@ -0,0 +1 @@ +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3488 diff --git a/devdocs/specs/authentication.txt b/devdocs/specs/authentication.txt new file mode 100644 index 00000000..9de31a3e --- /dev/null +++ b/devdocs/specs/authentication.txt @@ -0,0 +1,3 @@ +Authentication + + diff --git a/devdocs/specs/client-group-deprecated.txt b/devdocs/specs/client-group-deprecated.txt new file mode 100644 index 00000000..98fb3cb2 --- /dev/null +++ b/devdocs/specs/client-group-deprecated.txt @@ -0,0 +1,2 @@ +Replace client group with tags, see case 3441, 3373 + diff --git a/devdocs/specs/client-service-requests.txt b/devdocs/specs/client-service-requests.txt new file mode 100644 index 00000000..d3f5a12f --- /dev/null +++ b/devdocs/specs/client-service-requests.txt @@ -0,0 +1 @@ + diff --git a/devdocs/specs/clients.txt b/devdocs/specs/clients.txt new file mode 100644 index 00000000..d3f5a12f --- /dev/null +++ b/devdocs/specs/clients.txt @@ -0,0 +1 @@ + diff --git a/devdocs/specs/core-attachments.txt b/devdocs/specs/core-attachments.txt new file mode 100644 index 00000000..b292cf8d --- /dev/null +++ b/devdocs/specs/core-attachments.txt @@ -0,0 +1,6 @@ +# Attachments specifications + +## TODO + +- Need core dlToken route for not just attachments +- There should be a notification for ops if a file attachment is physically not found when it has a db record see Attachmentcontroller line 407 \ No newline at end of file diff --git a/devdocs/specs/core-backup-and-restore.txt b/devdocs/specs/core-backup-and-restore.txt new file mode 100644 index 00000000..9902f10a --- /dev/null +++ b/devdocs/specs/core-backup-and-restore.txt @@ -0,0 +1,53 @@ +BACKUP AND RESTORE SPECS + +CASES + + +REQUIREMENTS + +BACKUP +- Backup to independent text format using JSON text files like DB DUMP and Discourse does +- Backup all attached files (encrypt?) +- Encrypt the backup file archive or each file (need to determine) so that the backup file can't be read independent of RAVEN + - Encryption key needs to be user selectable because we don't want any AyaNova user to be able to restore any other AyaNova users db + +- Optionally automatically send the backups to online destinations such as + - AWS web storage + - Dropbox + - Mailbox if under certain size + - Look into whatever apis are available for other online storage services +- Download backups +- Backup during closed window where server is not available for anything but read only operations during backup window +- User configurable backup time +- User configurable encryption key in environment variable? If not set then not encrypted?? + + + + +RESTORE +- Automatically closes api before RESTORE +- Restore from backup locations can save to? + - Or at least a method to fetch and list those backups to fetch to local? +- Upload a backup file for restoration +- Decrypts data during restore process + - Must use user provided key and there should be some kind of marker file or something to verify it decrypted properly and is a valid AyaNova backup +- Restore the attached files (decrypt?) +- Uses user configurable encryption key + +ROLES +- Ops ful, biz full can + - modify backup configuration + - Restore + - Backup + +- OpsLImited and biz limited can + - view the backup and restore configuration + - Backup + + + + + + + + diff --git a/devdocs/specs/core-check-for-updates.txt b/devdocs/specs/core-check-for-updates.txt new file mode 100644 index 00000000..645d03a6 --- /dev/null +++ b/devdocs/specs/core-check-for-updates.txt @@ -0,0 +1,10 @@ +CHECK AND UPDATE SPECS + +REQUIREMENTS + +- Must confirm current RAVEN is licensed before checking for updates with a current and valid support and updates license + - We don't want people to initiate an update if they aren't eligable + - We also don't want Raven to allow it to run if the new code was built after the current support and updates in the license expired + - Check internal license + - Check externally with Rockfish if licensed + - Probably best if it just has an update check route to Rockfish where it feeds license serial number (obfuscated?) to Rockfish so it can see which license it is currently using diff --git a/devdocs/specs/core-documentation.txt b/devdocs/specs/core-documentation.txt new file mode 100644 index 00000000..b395d20a --- /dev/null +++ b/devdocs/specs/core-documentation.txt @@ -0,0 +1,46 @@ +Documentation + +HELP MANUAL POINTS TO CONSIDER + +ONBOARDING + - The manual and/or guides and/or built into UI guided help needs to answer all the specific questions people have when onboarding + - For example a section for technicians, dispatcher, each business role and then under that answers to basic questions: + - How do I see my workorders that are open + - How do I see who is where today + - How do order inventory + - The old manual and guide weren't job and task oriented so it doesn't directly answer questions people have, + it just shows how to use features which isn't the way people approach it when familiarizing themselves. + +PRE-SALES + - Similar to onboarding but a higher level view, just basically answering the questions "Can it do XXX??" + - Perhaps it's the same as onboarding but you have to click through for ever increasing detail so you can abandon once your question is answered + + + + + + +- User docs and manual written in Markdown format (commonmark). +- MKDOCS tool used to generate static html "site" for docs +- Docs versioned into folders by major/minor version, so different docs for 8.1 and again different for 8.2. Patch revisions don't change docs. +- Static docs site incorporated into AyaNova backend so can view the docs for that release inside it directly. +- Docs written in parallel with development as per agile principles so as soon as a feature is added it's documented as well. + +## Markdown for documentation +- [Commonmark cheat sheat](http://commonmark.org/help/) +- [Commonmark testing 'dingus'](http://commonmark.org/help/) +- Convert markdown to other formats with this tool http://pandoc.org/ + - Useful for standalone docs maybe + +- Auto generate a static site from markdown docs with this developer tool: http://www.mkdocs.org/ +- Material theme for MKDOCS https://squidfunk.github.io/mkdocs-material/ +- MKDocs WIKI (themes, solutions to tricky things etc) https://github.com/mkdocs/mkdocs/wiki + +** MKDOCS ** +- HOW TO RUN IT: python -m mkdocs serve to run the server, python -m mkdocs build to build the site +- package is tragically out of date on my debian, have to install manually +- Already had Python 2.7.13 (mkdocs page says 2.7.2 in it's example but 2.7 is ok) +- didn't have PIP so have to get that, selected python-pip in synaptic (v9.0.1-2) +- Installed mkdoc by running pip install mkdoc +- apparently not in path have to preface command with python like so: python -m mkdocs new ayanova +- Also installed the Material theme which looks a lot nicer diff --git a/devdocs/specs/core-email-ops.txt b/devdocs/specs/core-email-ops.txt new file mode 100644 index 00000000..524261b7 --- /dev/null +++ b/devdocs/specs/core-email-ops.txt @@ -0,0 +1,18 @@ +EMAIL OPS SPECS + +CASES + + + +REQUIREMENTS + + +- Supports notifications via generator + +- Needs to deliver email + - Plain text, probably not html for now + - Attachments are a possibility and should be allowed for + - A simple api that can be called by generator and others as necessary + + +- FUTURE: some future features may require reading email as well diff --git a/devdocs/specs/core-generator.txt b/devdocs/specs/core-generator.txt new file mode 100644 index 00000000..abb30259 --- /dev/null +++ b/devdocs/specs/core-generator.txt @@ -0,0 +1,110 @@ +GENERATOR SPECS + +CASES + + + +REQUIREMENTS + + +- Is accessible from OPERATIONS endpoint see core-long-running-operations.txt + + +Use db table OPERATIONS queue to submit jobs for generator to handle. + +Generator looks in table periodically for work to do and if the criteria matches starts the job by calling out to the submitting code + - Generator is only concerned with the job starting and ending and logging, that kind of thing, it does no work on it's own directly + - Start date time + - Is api locked and can it run when api is locked + - Is job exclusive, i.e. no other jobs while it's running and or api is locked + - Updates into a operationstatus table and endpoint that is open when api is locked + - Deletes status succeeded operations and their operationstatus entries after 24 hours automatically + - Deletes status FAILED operations and their logs after 2 weeks automatically + - New jobs cannot be submitted via the rest api when the REST interface is locked but it can be queried to see what is happening + - Hands off completed jobs back to submitter for resolution + - I.E. if a workorder was generated from a PM then it goes back to the PM and says "DONE THIS JOB" or "FAILED THIS JOB" and the PM code handles resubmitting a new job for next time + - This way generator does less and isn't responsible for hairy complex code that is best handled at the source of the job + + - JOB is actually handed off back to the object that submitted it into the db in the first place to do the work + - i.e. generator sees a send notification job for a workorder status change, it is ready to go so it in turn passes the job ID or info back to the workorder-process-notification code to handle and takes back the result + - i.e. a workorder may submit a "notify users about status change" job, generator sees it and in turn calls notify users code in workorder which in turn creates new jobs to send each email + generator then sees send email jobs and in each case hands them off to the email processor to deal with + + + + +TODO GO THROUGH GENERATOR CASES AND v7 GENERATOR CODE, COMPILE A LIST OF WHAT GENERATOR NEEDS TO DO + - Need a list of everything RAVEN generator will need to do + - BREAK out into small separate concerns + - Make a spec doc for each separate concern (i.e. one for the process loop, one for the LRO stuff, one for the physical delivery etc) + + +JOBSWEEPER + - Need a maintenance job class that handles periodic routine internal maintenance CoreJobManager + - Submits and handles or hands off routine jobs + - Clean up database (look for orphaned records) CoreJobDBMaintenance + - Check for license maybe or check license validity? (had some plan to automatically pull license from rockfish, to accomodate monthly rentals) CoreJobLicenseCheck + - Clear out completed jobs CoreJobSweeper + - Probably dozens of other things + +NOTIFICATION SWEEPER + - Maintains notifications tables: cleans out old ones, finds ones that are "stuck", notifies OPS about weirdness if found, removes undeliverable ones, counts retries etc + + + +RAVEN JOB SEQUENCE OF OPERATIONS + +Hypothetical Widget UPdated notification job + - Widget UPdated + - Calls into notification code in WidgetBiz or JobObject or maybe INotifyObject to see if it should create a notification + + + + +HOW AND WHAT v7 GENERATOR DOES + +These functions are called every 5 minutes by Generate in Winform desktop standalone. +- GenProcessPM.GeneratePMWorkorders(); +- GenProcessDeliveries.DeliverNotifications(); +- GenProcessClientNotifications.DeliverNotifications(); + + +GeneratePMWorkorders + - Calls for a list of pm id's of not expired and generate on date in future (for some reason it's just in future, not a specific time range, seems buggy) + - Taks the list of id's and passes them to workorder generate from pm which in turn... + - Workorder.cs Makes workorders from PM workorders copying pm wo data into service wo data + - Workorder.cs After making the service workorder the last bit of code calculates the next pm date and then adjusts the source pm to the next date + +DeliverNotifications (techs) + - Get a list via notificationlist : + // List of notifications formatted for delivery + /// and screened to be deliverable + /// + /// + /// + /// Loops through all NotifyEvenRecords + /// For pending type events, checks to see if within users selected notification window + /// foreach NotifyEventRecord it adds a NotificationListInfo + /// for each open delivery window for each subscriber to that event + /// As it Processes each deliverable notification it formats it to users + /// locale and preferences for size etc and keeps track of the address and + /// delivery type. + /// + /// + /// End result is a list ready to deliver. + /// As each one is delivered Ok it should be deleted from the NotifyEvent table by the + /// delivery Process that uses this list. + - Iterate the list and depending on deliver via method required: + - SMS (smtp) + - SMTP (email) + - POPUP + - AYANOVA MEMO + - Log delivery and remove event after each item is delivered individually + + +DeliverNotifications (clients) + - Gets a list of client notifications that are ready to deliver (they hold the entire message in EML format) + - Attempts smtp server connection, if a problem then logs it and optionally, based on global setting, will bail and retry again later, otherwise proceeds through code which in turn deletes each notification it can't deliver + - Delivers via smtp, if fail logs failure. Either way deletes the notification so it's gone forever + + diff --git a/devdocs/specs/core-import-v7.txt b/devdocs/specs/core-import-v7.txt new file mode 100644 index 00000000..dd25e45c --- /dev/null +++ b/devdocs/specs/core-import-v7.txt @@ -0,0 +1,71 @@ +IMPORT FROM V7 SPECS + +CASES +- https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3503 + + +REQUIREMENTS + +- LImited area of concern: rather than trying to do all types of import, I'm going to write this as if it's all for v7 only + - only when I write the next importer will I see if there are any savings of combining objects, but for now small classes with single responsibility +- Import v7 data into RAVEN via datadump plugin for v7 +- ROUTE: endpoint to allow upload of v7 datadump zip file . +- ROUTE: endpoint to allow delete of uploaded datadump file. +- ROUTE: endpoint to Show list of datadump files uploaded. +- ROUTE: endpoint to trigger non-selective import of specified datadump file (import all) + +- FUTURE - ROUTE: endpoint to download a picklist collection of objects found in dump suitable for user to then select specific objects (or type of objects) to import +- FUTURE - ROUTE: endpoint to trigger selective import from an uploaded datadump file + - future thing if required because user always has option of editing zip file and removing what is not required to be imported + - Supports specifying what to import so can just pick clients or something. + - Pick by name of folder I guess within zip since each object type is in it's own folder + + + +- IMPORT + - user selects all or collection of folders to import from zip + - User is returned a jobid that the client can use to display the activity log + - Importer opens the archive file and iterates the folders + - Each type of object has a corresponding biz object that handles importing that type + - So, for example, each client json is handed off to a corresponding ClientBiz for import + - Importer updates the opslog as it's doing the import with summary information (3 clients imported successfully etc) +- Should close api while it's doing the import. +- Datadump files should be in backup files folder + + +ROLES +- Ops ful, biz full can submit jobs +- OpsLImited and biz limited can view the jobs (already coded) + + +OBJECTS + - [ImportAyaNova7Controller] + - [ImportAyaNova7Biz] object to back controller routes, submit job, run job and pass off import to each biz object in turn that implements: + - [IImportAyaNova7Object] interface for each biz object (e.g. ClientBiz) + - Import(AyaTypeAndID,JSONDATAfromimportfile) + + + + +OPERATION SEQUENCE + +The upload / delete / show list of datadump files part is standard api stuff and doesn't need special handling nor locking the server +Importing + - Triggered by ops user remotely by selecting datadump file for import + + + + +SCHEMA + + +- It would be helpful to have a importmap table that is used temporarily during import to store a map of v7Guid's with their imported RAVEN type and id +- This way it can be a lookup table to quickly set other imported data, i.e. client id's to match to units being imported. +IMPORTMAP + - ObjectType + - ObjectId + - v7Guid + + + + diff --git a/devdocs/specs/core-license-key-system.txt b/devdocs/specs/core-license-key-system.txt new file mode 100644 index 00000000..2a6559bf --- /dev/null +++ b/devdocs/specs/core-license-key-system.txt @@ -0,0 +1,228 @@ +License key / products + +(see marketing-sales-planning.txt specs doc for specific reasoning behind this) + + +CURRENT WORKING STATUS: + +2018-06-01 +Rough framework is in place, can fetch a key and can request a key but nothing really connected at the Rockfish end. +Rockfish will need some ui and other code to handle it +Putting that off until closer to release as Rockfish will no doubt change before then anyway (also involves a lot of UI work I'm not into right now) + + + + + +PROCESS + +LICENSE BOOTSTRAPPING + - RAVEN when boots and inits license will ensure a GUID DBID value is set in LICENSE TABLE + - This is used to match individual databases to licenses and avoid problems with registration names being the only differentiator as in v7 + - Also this is the new fetch code + - RAVEN DB is empty (of biz data) it's license locked and a User MUST EITHER: + + 1) request a trial key for an empty db by filling out a form in RAVEN (or using api tool) + - see "trial process" below for details + + + 2) Fetch a paid for license + - or, in future purchase right inside RAVEN + - RAVEN fetches and installs the license and the rest proceeds as normally + +TRIAL PROCESS +============== +FOR USER: Install RAVEN, boot, go to client endpoint in browser, get prompted for first test of contact with ROCKFISH server, + once that is ok then get prompted to fill out a form to request a trial key or attempt to fetch a key already ready for them + User fills out form providing registration name and email address, hits send, told to check email to confirm address then will receive a key after that. + They are told they can only request a new trial by erasing all the data first + +FOR CODE: + - RAVEN user requests a trial key for an empty db by filling out a form in RAVEN (or using api tool) + - registration name, email address are required fields + - This request is stored in the db because it may need to be re-sent later on subsequent erasure of biz data + - RAVEN makes and stores internally into a new DB a DBID value which uniquely identifies *THAT* database installation + - RAVEN transmits the request details (with dbid value) to ROCKFISH + - RAVEN starts a repeating job that will periodically check the ROCKFISH server for a license response + - ROCKFISH validates the request by verifying the email address provided is (is still) valid and by getting our approval after that. + - Verifies email by sending an email and waiting for a response to verify the email is valid + - If the email is valid then it creates a trial request record for us to view and approve or not approve with cause entered as text + - This part we might automate in future but for now it's a good "fuse" + - The DBID value is associated permanently with a site in ROCKFISH + - We get a notification of a request sitting in ROCKFISH and we approve it or not + - IF APPROVED: + - ROCKFISH generates (or looks up if repeat) a customer record, flags it as a trial type customer with a created date (for later GDPR removal or turning into a real customer record) + - (ROCKFSIH UI needs a grouping for trial customers for main customer list) + - ROCKFISH stores the DBID value in the SITE record + - ROCKFISH generates a valid TRIAL license DBID code is the temporary fetch code. + - ROCKFISH Ultimately should do this automatically unless we specifically flag something + - IF NOT APPROVED + - ROCKFISH creates a not approved type of license with the fetch code + - ROCKFISH sends and email to the user explaining that it's a fail and why + - RAVEN knows a request has been sent and has a license job that checks to see if a license is available periodically + - ROCKFISH returns a "no-op" type response if there is no license + - ROCKFISH returns a "FAIL" type response if there is a fail with reason code to display to user + - RAVEN CANCELS the check for license job and removes the request + - ROCKFISH returns a license key when available under the dbid fetch code + - ROCKFISH tags the license as "FETCHED" = true so it can't be fetched twice + - RAVEN CANCELS the check for license job and attempts to install the key in normal process and initialize + + +PURCHASE PROCESS +================ + + +FOR USER: +Assuming ShareIt system still in place: +Customer makes a purchase, (possibly through RAVEN in which case the DBID is sent with the url to ShareIt to be filled into the purchase page automatically) +Customer enters their DBID as instructed into a box on the purchase page. The order is not valid without it and it's a required field. +customer can easily copy their DB ID from within RAVEN + +We get the order and it gets transferred to ROCKFISH, license key is generated and an automated email notification is sent to user instructing that the license is ready for "pickup". +User (who is OPS full or limited or BIZ full) goes into the license page in RAVEN UI and selects "Check for new license" at which point the server does the check passing the GUID along with the check, if new license then it installs and inits and informs user. +If it's a fail for some reason informs user and no change to current license. + + +FOR CODE: + +- RAVEN needs a DBID route in licensing controller to return the DBID available to OPS full or read only OR BIZ full rights only. +- ROCKFISH needs a way to send a license key email to the customer automatically upon generation +- ROCKFISH needs a new key generation page with the new options on it + + +RENEWAL PROCESS +=============== + +FOR USER: +If perpetual: They get a renewal upcoming notice if yearly (!=ServiceMode) from ROCKFISH so they can update payment info or cancel, RAVEN will start a license check job immediately before expiry +If Service: they do nothing, it's understood that it will start to check automatically for a new key around the time of expiry of the old key until it receives a CANCELLED response or a key or a FAIL + +FOR CODE: +See below, basically raven will check with rockfish automatically and handle any problems + + + +"2018" KEY FORMAT +================= +- JSON format +- secured with hash signature, only we can issue valid keys +- Features, All licenseable items (and some configuration features) are in a single "Features" collection scheduledusers, accounting "Service" (meaning it's rental), "Trial" meaning it's a trial etc. + - "ServiceMode" feature which indicates the license is for renting not perpetual so that it can trigger more constant check for new licenses or offer rental specific features and functionality + - "TrialMode" feature which indicates it's a trial so a different UI can be presented in some areas with sales links etc + - Futureproof, can put anything in there +- LicenseExpiration date that applies to all features together as a group (no more individual expiry dates) + - When the license expires it stops working. NO read only mode, nothing works except some ops routes to install a new license, that's it +- MaintenanceExpiration date that applies to support and updates subscription for all features of that license (no individual feature support expiry) + - Used by "check for updates" code to see if they are eligable for an update and to auto-update +- ID This field is critical and should contain the customer ID followed by the license ID, so for example 34-506987 + - THESE points here below for this item may be invalid if we go with DBID instead, but keeping as seems to make sense + - This way we can verify with automated tools the customer requesting without sending an actual name in text and also that they have the latest key or not + - RAVEN says "This is my key 34-506987" to ROCKFISH which replies with "Here is a newer key [key]" or "You are up to date" or "Revoked for reason: [non payment]" + - (Test keys are all 00-serialid) +- DBID + - This field is the unique DB id (GUID) of the database in use that is first generated by RAVEN when it first boots with an empty db + - It is stored in the global settings of the database and is never erased once it is set + - It is also used in conjunction with the ID field or possibly on it's own to be the fetch code +- RegTo: ALL TRIAL KEYS ARE REGISTERED TO SOMEONE NO SUCH THING AS TWO LICENSES IN CIRCULATION WITH THE SAME NAME (i.e. no more "Unregistered trial" meaning it's a trial, every user will have a specific name to test it out) + - Rockfish will issue a trial key upon first request from empty db + - An empty regto is an invalid key. +- LicenseFormat + - there will be a LicenseFormat version field which will be initially "2018" + - This is for future changes to how license is formatted to ease the code burden of detecting that + - Old future release versions should work with new licenses but not the reverse + + + +ROCKFISH CODE +- FETCHROUTE: Needs a route to automatically check for presence of new licenses from RAVEN, fetch and install them + - Will return either a license or an object indicating error or nothing new to return + + +- UI: Needs a whole lot of ui and code to support + - automatic generation of license key from manual and future automated billing + - Key stored and served when required with challenge and response system by past key id and customer number maybe + automatic billing and renewals but initially generate licenses automatically based on payment info and all that shit + +RAVEN CODE +- Raven if an empty db can send a request for a key to Rockfish with a registered name +- If db empty on boot set a Guid value in the global settings table that uniquely and permanently identifies that database +- RAVEN will have two addresses for fetching a license key to different domains so that we can stay up in case of an issue + - i.e. it will try rockfish.AyaNova.com first but if it fails then fallback to rockfish.helloayanova.com as secondary +- If there is an existing key RAVEN will automatically check for a new key close to the expiry period of the old key + - If a FAIL is returned it will stop checking and tell the user at which point they must manually start the check after fixing the issue + - If a NOOP is returned it will reschedule to check again later + - If a CANCELLED is returned then the customer is no longer active and it will never check again unless user manually forces a one time check and the license changes + - If a license is returned RAVEN will attempt to install it and also clear the running check job and also clear the CANCELLED status + +- RAVEN LICENSE OBJECT / TABLE add a DBID guid column +- RAVEN LICENSE OBJECT / TABLE add a LastFetchStatus column corresponding to an enum of FAIL, ACTIVE, CANCELLED +- RAVEN LICENSE OBJECT / TABLE add a LastFetchMessage column with the last message from the ROCKFISH license route server (so FAIL can be stored and communicated to user) + + +USAGE: + +- MUST be able to support monthly billing cycle (automatic license installation or approval so user doesn't have to install key every month) + - Rockfish should have a licensed yay or nay or new available route for RAVEN to check periodically and also users can trigger a force check +- RAVEN checks periodically for new license to fetch in line with billing cycle or expiry cycle. + - SAFE fallback if can't contact license server and allow a few misses before something kicks in, but not to allow people to use unlimited by blocking rockfish for example + + + + + + + + + +PRODUCT LICENSE CHANGES FROM v7 + + +Still optional and purchased separately: +QBOI, QBI, PTI + +Possible idea: sold as "Accounting add on" and the user can use one of those of their choice and can switch which means 1 product instead of three which might make keys easier. + Possible downside is that we can't track who uses what or what we sell how much of so puts a kink in marketing or planning for deprecation or where to spend + effort, however there are other ways of addressing that. + + +//INCLUDED ADD-ON's +OLI - INCLUDED / TURNED INTO GENERIC FEATURE IMPORT / EXPORT CONTACTS and SCHEDULE TO ICAL +OutLookScheduleExport - INCLUDED / TURNED INTO GENERIC SCHED EXPORT ICAL FORMAT +Export to xls - Included with RAVEN +QuickNotification - Included with RAVEN +ImportExportDuplicate - Included with RAVEN +RI/WBI/MBI - UNNECESSARY with RAVEN + + +This all brings up the matter then that we might only have two things to license: the number of users and whether there is accounting or not. + ?? PROS and CONS INLINE WITH RAVEN DESIGN GOALS ?? + + + + +THOUGHTS +Raven license key system specs: + + +- CONSIDER supporting non-connected scenario where user is not internet connected + + + + + + + +??? +- Don't worry about obfuscation at first (maybe ever, I mean, really) +- Need automated routine that checks with rockfish to fetch a license automatically when necessary + - I.E. it's sub runs out so it starts checking if there is a newer license available and if so fetches it +- Need an alternative route and UI in Rockfish for RAVEN style license handling +- Must use a different signing key than AyaNova 7 so we don't expose v8 key stuff, (although, it's just the public part of the key to validate right?) +===================================== + +RESEARCH SOURCES +- http://www.reprisesoftware.com/blog/2017/08/implement-a-recurring-revenue-license-model/ +- https://www.linkedin.com/pulse/20140819084845-458042-the-change-from-perpetual-to-subscription-based-software-licensing +- http://software-monetization.tmcnet.com/articles/429528-long-but-rewarding-road-a-recurring-revenue-model.htm +- How to calculate pricing in a service business: https://www.patriotsoftware.com/accounting/training/blog/how-pricing-services-strategies-models-formula/ +- SAAS pricing models: https://www.cobloom.com/blog/saas-pricing-models \ No newline at end of file diff --git a/devdocs/specs/core-localization.txt b/devdocs/specs/core-localization.txt new file mode 100644 index 00000000..cadd9e30 --- /dev/null +++ b/devdocs/specs/core-localization.txt @@ -0,0 +1,103 @@ +# Localization specifications + + +REQUIREMENTS + - Keys are text, human readable and as short as possible + - Not numeric ID's for these, strictly textual + - values may have substitution tokens in them for certain things + - DataDump plugin will export and import any custom locales that did not come with AyaNova 7 + - Dump needs to check if the "stock" locale has been edited or not before exporting + - Only edited ones are exported + - For example if someone edited the Spanish locale then it would dump as "Spanish-Custom" (or whatever the word for custom is in that language) so as not to interfere with our stock Built in Spanish in Raven + - The documented renaming (below) will need to be automated during import of v7 stock locales to migrate to the new key values + - Two kinds of locales: Stock and Custom. + - Stock locales are stored in db and not user editable + - STock locale names are whatever the international name for that locale is like "esp" or "fr" etc + - Custom locales are stored in the database and are user customizable + + ROUTES + - GET ROUTE that provides a pick list of locales + + - GET ROUTE that returns all key value pairs when requested for a specific locale + - This one is for editing purposes or for export to disk + + - GET ROUTE that returns a list of specific key value pairs for a requested locale and specific list of locale keys provided + - This one is for day to day ops and will be called on any client opening a new area of UI they have not previously opened + + - PUT ROUTE that accepts a list of key value pairs and a specific locale and updates the current values in db from the provided list + - if locale name key provided is one of our stock ones then it errors out as you can't change the stock locales + - if locale doesn't exist in db errors out + - biz full rights only + + - POST ROUTE that creates a new locale duplicated from an existing locale and copies all the values from the existing locale + - Post object {sourceLocale:"English", newLocale:"MyLocale"} + - Errors if already exists with that name + - Sets it to stock=false so it can be edited + - This is also how you rename a locale + + - DELETE ROUTE for deleting any non-stock locale + - Can't delete current DB default locale (specfic error) + - Users of that locale will be reset to current DB default locale + + + + +CHANGES MADE TO KEYS FROM v7 + + - Replaced all [.Label.] with [.] + - Replaced all ["O.] with ["] this needs to be a case sensitive change!!! +- Removed duplicate key [WorkorderService.CloseByDate] that resulted from last change of O. (the first one between workorderservice and workorderstatus) + - Replaced all [.ToolBar.] with [.] + - Replaced all [.Toolbar.] with [.] + - Replaced all [.Go.] with [.] + - Replaced all [.Command.] with [.] +- Removed duplicate key created by last operation: [UI.Search] (removed second longer one that refers to database) + - Replaced all [.Error.] with [.] + - Replaced all [.Object.] with [.] + - Replaced all ["UI.] with ["] (and removed exact dupe keys created as a result) + - Replaced all [.] with [] +- Removed dupe WorkorderItemOutsideService (removed the one with the longest value) + - Replaced all ["AddressAddress"] with ["Address"] + - Replaced all ["ContactPhoneContactPhone"] with ["ContactPhone"] + - Replaced all ["ContactPhonePhone"] with ["ContactPhone"] + - Replaced all ["PurchaseOrderPurchaseOrder"] with ["PurchaseOrder"] + - Replaced all ["WorkorderItemMiscExpenseExpense"] with ["WorkorderItemMiscExpense"] + - Replaced all ["WorkorderItemTravelTravel"] with ["WorkorderItemTravel"] + +Note: still some dupes but...fuck it + + + +- TODO: As I code, I will select lt keys as required enter them below + + + +- TODO: Some of the keys are new and not translated from English, when all is done and the keys that will be carried forward are determined, check for untranslated ones + - Use Google translate to get a rough approximation. + - A technique to get a good translation would be to try various synonyms out and try to zero in on the commonality in translation to ensure a word is not being completely misunderstood to get a better translation + - I.E. if different forms of the phrase result in similar words in the other language then it's probably Gucci + + +CLIENT + - Client fetches localized text on an as required basis form by form and caches it locally until cache is invalidated + - Cache invalidated by either a timeout or possibly receiving a message from the server. + - Open an edit form + - client checks local cache (do I have the values for the list of required keys??) + - YES: just use it + - NO: Send a list of keys to the server along with the user id that are required for this form and get back the LT, put it in the cache + - User id required because someone might edit their locale or the locale name and so it needs to check via the user account what the locale is + +This way there is no wasted space at the client caching stuff that will never be used + +CHANGES: + - If the text is changed at the server then a notification should occur for clients using that local to invalidate their cache + - Although, that would be a pretty rare event so...maybe not so much, a logout could clear the cache or a login I guess + + + +LOCALIZED TEXT KEYS ACTUALLY USED +=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= +NewKeyValue, OldKeyValue +------------------------ + +HelpLicense, UI.Help.License \ No newline at end of file diff --git a/devdocs/specs/core-log-business.txt b/devdocs/specs/core-log-business.txt new file mode 100644 index 00000000..ebc8ec60 --- /dev/null +++ b/devdocs/specs/core-log-business.txt @@ -0,0 +1,29 @@ +Business history log + +FROM CASE 79 + +A central event log used to track changes to business objects and events of significance in AyaNova. + +Auto prunes (can be set) + +Has some sort of checksum or verification so we can tell it wasn't fucked with + +Consumed by various widgets for record history purposes +Each object defines it's own set of event id's of significance (int enum) in addition to some events common to all objects: + +1=created +2=modified +3=deleted + + + +EVENT LOG DB SCHEMA +------------------------------------ +AYTYPE (object type int), +AYID (object id), +AYEVENT (event of interest type int defined in object), +TIMESTAMP (unix epoch), +USERID, +TEXTRA (text field to identify stuff that can't be retrieved from source object, i.e. deleted record name) + + diff --git a/devdocs/specs/core-log-security.txt b/devdocs/specs/core-log-security.txt new file mode 100644 index 00000000..9a90d752 --- /dev/null +++ b/devdocs/specs/core-log-security.txt @@ -0,0 +1,14 @@ +Security history log + +FROM CASE 1998 + + +A central event log used to track security related events of significance in AyaNova. + +- Authentication events (login, logoff) +- User creation / deletion +- User role changes + + +Auto prunes (can be set) +Has some sort of checksum or verification so we can tell it wasn't fucked with diff --git a/devdocs/specs/core-long-running-operations.txt b/devdocs/specs/core-long-running-operations.txt new file mode 100644 index 00000000..20334956 --- /dev/null +++ b/devdocs/specs/core-long-running-operations.txt @@ -0,0 +1,76 @@ +JOBS / LONG RUNNING OPERATIONS SPECS + + + +CASES + + + +REQUIREMENTS + +- An endpoint to view jobs in the table +- An endpoint to view logs of jobs +- A db schema (see below) +- biz object callable from other biz objects with following functionality + - Submit jobs + - Remove jobs + - Remove all jobs for object type and id (called when deleting an object) + - Update status of jobs + - Log ops of jobs + + + +- OPERATIONS endpoint: + - GET JOB LIST Check a list of jobs (with rights to do so). Works even when api is locked. + - GET /operations returns list of jobs sorted by lastActionDateTime or createdDateTime via query parameter + - { + "jobid":"1234", + "createdDateTime": "2015-06-19T12-01-03.45Z", + "lastActionDateTime": "2015-06-19T12-01-03.45Z", //<----determined from log, not stored in AOPSJOB + "name":"Send notifications | Import data | Backup | Restore", + "exclusive":"true/false", + "status": "sleeping | notstarted | running | succeeded | failed", + "log":"/operations/log/1234" + } + +- ROUTE: "OPERATIONS" endpoint to get log of long running operation /operations/log/1234 + - [ + { + "DateTime": "2015-06-19T12-01-03.45Z", + "Entry": "Imported 4 clients, 3 were duplicates and ignored" + }, + { + "DateTime": "2015-06-19T12-02-03.45Z", + "FAILED to import 5 workorders - data format invalid" + }, + { + "DateTime": "2015-06-19T12-04-03.45Z", + "Operation completed with errors" + } + ] + +- FUTURE: delete jobs that have not started yet +- FUTURE: cancel jobs and cancellation token + + + +SCHEMA +=-=-=-= + +AOPSJOB + - jobid long NOT NULL INDEXED UNIQUE (initially I'll use linux epoch when job created, used to tag logs etc, but keeping this open for a change later) + - OwnerId NOT NULL + - Created NOT NULL + - Exclusive NOT NULL bool (true=close api and don't run any other jobs, false=process async and keep api open) + - StartAfter NOT NULL INDEXED (datetime to start the job, in cases of start now jobs the date will be minvalue) + - jobtype enum int NOT NULL of the jobtype which is an enum of all possible job types (i.e. NotifyClosed) + - ObjectId NULL source of job object id (i.e. workorder id) + - ObjectType NULL source of job object type (i.e. workorder) + - descriptive name text NOT NULL for display in UI only, isn't filtered + - jobstatus enum int NOT NULL type (one of "sleeping | notstarted | running | succeeded | failed") + - jobinfo text NULL (json string of extra info required for job, maybe the name of an import file or who knows what, anything really can be put in here as long as it's shortish) + +AOPSJOBLOG + - jobid long not null + - created not null (indexed? Will be ordered by this a lot but it's the natural order...no?) + - statustext NOT NULL diff --git a/devdocs/specs/core-notification.txt b/devdocs/specs/core-notification.txt new file mode 100644 index 00000000..d20dea96 --- /dev/null +++ b/devdocs/specs/core-notification.txt @@ -0,0 +1,40 @@ +# Notification specifications + + +SCRATCHPAD IDEAS +=-=-=-=-=-=-=-=-=- +Hypothetical sequence of operations for designing raven notification and tie into jobs and processor + +WidgetStatusChange notification + - OnChange event triggered in [WIDGETBIZ] with before and after state of widget in question (immediately after save or delete or whatever is of interest) + - OnChange processor See if any users are subscribed to this event [CENTRAL_NOTIFICATION_CLASS THIS MUST BE SUPER EFFICIENT AS IT WILL BE HAMMERED] (events of interest / core central event bus handler of some kind??) + - CENTRAL_NOTIFICATION_CLASS event of interest should cache in memory events of interest and trigger cache invalidation by [EVENT_OF_INTEREST_CLASS] subscription change + - If no then bail out early + - If yes then compares the before and after state of the widget and the given list of events of interest and processes notifications in turn + - Create a notification event in a table of pending notification events [CENTRAL_NOTIFICATION_CLASS] + - See v7 schema for ideas + +Deliver notifications + - Job triggers and HandleJob is called on [CENTRAL_NOTIFICATION_CLASS] which in turn checks if any events ready to Deliver + - Hands off NOTIFY_EVENT to deliver one at a time to a [NOTIFICATION_DELIVERY_CLASS] which in turn calls each [NOTIFICATION_DELIVERY_DELIVERY_TYPE-SMPT/POPUP/WHATEVER] + + +Maintenance + - [CoreJobNotificationSweeper class] maintains the notifications tables see generator spec for thoughts + + + + + + + + + +MISC +=-=- + +- NOTIFICATION SUBSCRIPTIONS +- NOTIFICATION DELIVERIES (user or all if manager account) + +- What is "Slack"? + - should we tie into it for notifications? \ No newline at end of file diff --git a/devdocs/specs/core-ops-metrics.txt b/devdocs/specs/core-ops-metrics.txt new file mode 100644 index 00000000..b806ce5b --- /dev/null +++ b/devdocs/specs/core-ops-metrics.txt @@ -0,0 +1,62 @@ +SYSOPS METRICS / HEALTH CHECKS + + + + +Right now as of May 7th 2018 here are the remaining outstanding issues for metrics: + +TODO OUTSTANDING ISSUES + +1) Need to make my own dashboard for non endpoint stats for Graphana. Actually a dashboard that covers all AyaNova would be good +https://www.influxdata.com/blog/how-to-use-grafana-with-influxdb-to-monitor-time-series-data/ +2) Save the dashboard as JSON text for the manual +3) See about making my own Grafana / INfluxdb container and include it in compose.yml for AyaNova server so can deploy it easily (with my own panels pre-built) +4) DOCUMENT +5) Skim below and see if I have covered it all. + + + + + +OLD OLD OLD OLD +This is old stuff I was using during research and initial implementation some of it may still be relevant later +=-=-=-=-=-=-=-=-=- + +APRIL 26 2018 - DID SOME RESEARCH, THIS IS ACTUALLY A VERY COMPLEX TOPIC AND BEST HANDLED WITH A 3RD PARTY TOOL +- There is an open source metrics tool and an open source db it can work with the is a time series data store (influxdb, elasticsearch) designed for exactly this scenario +- Influxdb has a docker container available +- Shitty thing is I would need some of this information for support purposes built in, not requiring some fancy 3rd party tools which are very cool for a large setup, +but a small one man show doesn't require that. +- Perhaps RAVEN can have a big corporate edition that is all intended to be containerized and comes with influxdb and preconfigured with metrics on. +- It handles both metrics and "HEALTH CHECK" issues in one package + +- I'm not sure if this is a v1.0 feature, though it would help in development to see what's what route writes +- If it can be an optional thing that can be turned on then that would be ideal +- https://al-hardy.blog/2017/04/28/asp-net-core-monitoring-with-influxdb-grafana/ +- https://www.app-metrics.io/ + + + +Ops Metrics + + - CASE 3502 Add metrics + - CASE 3502 Metric: record count in each table or at least major ones as a snapshot metric so can compare month to month. + - CASE 3497 ACTIVE user count - Log user login, last login and login per X period + - CASE 3499 "Slow" I want to know if anything is slow, not what the user says but what the code determines + + + + + - some kind of internal metrics to track changes over time in operations with thresholds to trigger logs maybe? + - Has to be super fast, maybe an internal counter / cache in memory and a periodic job that writes it out to DB, i.e. don't write to db metrics on every get operation etc + - Average response time? + - Busyness / unique logins or tokens in use? A way to see how many distinct users are connecting over a period of time so we know how utilized it is? + - Utilization? + - Areas / routes used in AyaNova and how often / frequently they are used (we could use this for feature utilization) + - CPU peak usage snapshot + - Disk space change over time snapshots + + + HEALTH CHECKS + - Comes with appmetrics: + - https://al-hardy.blog/2017/04/17/asp-net-core-health-checking/ \ No newline at end of file diff --git a/devdocs/specs/core-ops-support-info-log.txt b/devdocs/specs/core-ops-support-info-log.txt new file mode 100644 index 00000000..8c4a7f59 --- /dev/null +++ b/devdocs/specs/core-ops-support-info-log.txt @@ -0,0 +1,83 @@ +SYSOPS HEALTH CHECK / METRICS + +OK, considered this and a log is a log and all logs are relevant to sysops people so I'm going to treat all logging the same regardless and make an effort to ensure each log entry +is tagged with the relevant class name + +CRITICAL ISSUES +- Check for critical issues in a health check periodic job which also logs and metrics +- Critical issues should be logged first then sent via notification for system operators if subscribed +- + +METRICS +- metrics should be gathered in DB and reported on via UI for ops users and potentially in other formats down the road + + + +TODO LIST OF THINGS CODED THAT NEED TO BE LOGGED +- Items in code tagged with this: + - //TODO: core-log-sysop +- Generator failures +- IJobBiz derived objects failures + +- configuration changes ??? +- Install and uninstall feature changes +- Warnings (low disk space, slowness monitoring, db issues) (during health check JOB??) + + +"HEALTH CHECK" JOB +- things that need to be metric a sized are commented with //OPSMETRIC +- Maybe a "health check" job or "checkup" job that periodically asseses things and reports findings +- works in conjunction with metrics gathered maybe? + - Metrics would be a system that for example could get free disk space then get it again a few days later and project ahead to getting low and warning or simple when down to 10% warn or etc +- Anything we'd like to see from a support point of view would be useful too +- Go over the research doc to see what was recommended +- Dig up that guys example project on his blog that he was going to add metrics to. +- Brainstorm a list of recent support issues and what could be a benefit in dealing with them +- "Slowness" comes up a lot. + + +Ops Metrics + CONFIRMED REQUIRED + - Gather in memory and flush to db on a schedule is best + - CASE 3562 If found, count of mismatch of attached files in database vs file system + - CASE 3523 Log major ops related configuration changes (before and after snapshot) + - CASE 3502 Log feature or route or endpoint usage count as a snapshot metric so can compare month to month. + - CASE 3502 Log record count in each table or at least major ones as a snapshot metric so can compare month to month. + - CASE 3497 ACTIVE user count - Log user login, last login and login per X period + - CASE 3499 "Slow" I want to know if anything is slow, not what the user says but what the code determines + + RESEARCH / IDEAS / EXAMPLES + - Metric types: + - https://www.app-metrics.io/getting-started/metric-types/ + - Code example that deals with this issue: + - https://github.com/AppMetrics/AppMetrics/tree/dev/src/App.Metrics.Core + - Need more than one window into the data, for example we need a last few minutes (5?) view so people can see at a glance what is happening NOW + - But also need to know what was it historically. So maybe we need a NOW algorithm but also a HISTORICAL algorithm. + - Maybe a sliding scale of recency, so a 5 minute view, a THIS WEEK view and then a month to month view beyond that?? + - LIBRARIES + - Health check Health Checks give you the ability to monitor the health of your application by writing a small tests which returns either a healthy, degraded or unhealthy result. + - https://www.app-metrics.io/health-checks/ + - APP METRICS + - https://github.com/AppMetrics/AppMetrics + - Different types of metrics are Gauges, Counters, Meters, Histograms and Timers and Application Performance Indexes + - METRICS of a system: + - Network. Network metrics are related to network bandwidth usage. + - System. System metrics are related to processor, memory, disk I/O, and network I/O. + - Platform. Platform metrics are related to ASP.NET, and the .NET common language runtime (CLR). + - Application. Application metrics include custom performance counters "Application Instrumentation". + - Service level. Service level metrics are related to your application, such as orders per second and searches per second. + - USEFUL INFO HERE FOR SYSTEM METRICS LIKE MEMORY ETC: This document from Microsoft gives generally accepted limits for things like CPU threshold, memory etc in actual percentages + - Section "System Resources" here https://msdn.microsoft.com/en-us/library/ff647791.aspx#scalenetchapt15_topic5 + + - USEFUL EXAMPLE dashboard for web applications: + - https://sandbox.stackify.com/Stacks/WebApps + + + - some kind of internal metrics to track changes over time in operations with thresholds to trigger logs maybe? + - Has to be super fast, maybe an internal counter / cache in memory and a periodic job that writes it out to DB, i.e. don't write to db metrics on every get operation etc + - Average response time? + - Busyness / unique logins or tokens in use? A way to see how many distinct users are connecting over a period of time so we know how utilized it is? + - Utilization? + - Areas / routes used in AyaNova and how often / frequently they are used (we could use this for feature utilization) + - CPU peak usage snapshot + - Disk space change over time snapshots \ No newline at end of file diff --git a/devdocs/specs/core-regions.txt b/devdocs/specs/core-regions.txt new file mode 100644 index 00000000..990bc804 --- /dev/null +++ b/devdocs/specs/core-regions.txt @@ -0,0 +1,27 @@ +REGION SPECS + + + +CASES + +REGIONS:CLIENT:NOTIFICATION:GENERAL: - REGION feature changes case +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3454 + +REGIONS:GENERAL: - denormalize region id in each regionalized object +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3476 + +QUOTE:NOTIFICATION:CR: - Notifications for quote creation should be regionalized (or tags?) +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3564 + + + + +REQUIREMENTS + + +Definitive decision on regions: +Regions will be deprecated as a feature entirely, no region feature will be in RAVEN / v8. +Old data will import regions as tags, first the region will be imported as a tag then anything that is imported of that region will be tagged with that region. +Notifications will work via tags instead, users will be able to filter a notification to anything tagged with that tag. +Essentially being able to filter out a user from seeing data outside their region is not going to be a feature going forward. +Roles will be the primary way to restrict what users see and various filters by tag for certain ops like notification etc. \ No newline at end of file diff --git a/devdocs/specs/core-reporting.txt b/devdocs/specs/core-reporting.txt new file mode 100644 index 00000000..c0197bc8 --- /dev/null +++ b/devdocs/specs/core-reporting.txt @@ -0,0 +1,11 @@ +REPORTING SPECS + +CASES + + + +REQUIREMENTS + +- All v7 reports ported to RAVEN + - ALL Fields even the ones that don't show on the report but are available for adding to a report in the editor need to be available + diff --git a/devdocs/specs/core-roles.txt b/devdocs/specs/core-roles.txt new file mode 100644 index 00000000..8fae428b --- /dev/null +++ b/devdocs/specs/core-roles.txt @@ -0,0 +1,72 @@ +# Roles specifications + +From case https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/1809 + +RAVEN will replace security rights system of v7 with a role based system instead +I'm using an int flags enum which means a maximum of 32 possible roles unless I bump it up to a long but don't really want to as this number will be thrown around the api a lot + + + +TODO: Fill this out as I code. + +**DELETE RIGHTS*** +If you can modify an object you can delete an object + + +**OWNER LIMITED ROLES** +Limited roles in some cases can create an object but can only edit or delete objects they created + +## ROLES + +### None +No rights, not settable, just for internal usage in code + +### BizAdminLimited +Intended for a business administrator / supervisor who wants to monitor the business, kpi, reporting etc, but doesn't actually get to change anything. +Suitable for the "big boss" who isn't trusted to make actual day to day decisions but can review anything. + +**RIGHTS** +- Read only access to everything (except OPS stuff) +- Full access to management reporting, KPI etc, but can't change them substantially, just sort, filter etc. + + +### BizAdminFull + +Basically the v7 manager account stuff with full rights to everything other than OpsAdmin stuff. + +**RIGHTS** +- Full access to all AyaNova objects with the sole exception of OPS related stuff +- Grants roles to other users +- Licensing +- Business related configuration settings +- All management and KPI stuff + +### DispatchLimited + +### DispatchFull + +### InventoryLimited + +### InventoryFull + +### Accounting + +### TechLimited + +### TechFull + +### SubContractorLimited + +### SubContractorFull + +### ClientLimited + +### ClientFull + +### OpsAdminLimited + +### OpsAdminFull +backup, troubleshoot, dashboard of throughput, db administration, all the stuff needed to keep RAVEN up and running and monitor any issues in operations of it, nothing to do with business stuff or actual business data + + + diff --git a/devdocs/specs/core-search.txt b/devdocs/specs/core-search.txt new file mode 100644 index 00000000..c9839b5e --- /dev/null +++ b/devdocs/specs/core-search.txt @@ -0,0 +1,99 @@ +SEARCH SPECS + + + +CASES + +SEARCH: - to have ability to filter by client +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/1503 + +SEARCH:UI:GENERAL: - Search in V8: should be integrated, not a separate form +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/1672 + +SEARCH:NEW: - Search dictionary: Auto remove orphaned words +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/1878 + +WORKORDER:PARTS:SEARCH:CR:DUPLICATE 3358: - Parts Search on Parts Grid +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3310 + +WORKORDER:PARTS:SEARCH:CR:DUPLICATE 3310: - Ability to ‘search’ for a part while in a WO or PM +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3358 + +UI:WORKORDER:CR: - Have a search box for clients instead of having to use the slider to find a client +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3376 + +SEARCH:UI:GENERAL: - Be able to search from anywhere in any screen +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3459 + +SEARCH:WORKORDER:PO:QUOTE:PM: - Workorder and other numbered items need to be searchable by their number +https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3506 + + + +REQUIREMENTS + +- USE-CASE: Central text search for any match. Can include tags. Can specify a type of object. +- USE-CASE: In-object text search for the typeandid that user is in, e.g. when in Client info form can search on that client. +- USE-CASE: Picklist / chooser Search for text of a specific type of object, e.g. find all Clients that contain "squirrel" to drive picklists everywhere +- USE-CASE: No snippet (excerpts) just name and type. I think for the initial release I'm *not* going to include snippets with result for several reasons: + - Performance: better to get an immediate result than to wait a while to get excerpts that + - Bandwidth: will trigger a lot of probably unneeded Bandwidth and chew up cycles on the db and server + - Can be added later based on feedback (i.e. a lot of bitching) + - For performance it would be something that runs *after* the search results are returned, either on demand (user clicks on excert button, or slowly fills in the list async) + +- NAME: in search index include a bool indicating the word is actually part of the name or equivalent of the object, this will make name searches WAAAAYYY easier!!! + - in non named objects it's whatever the primary identifier is, i.e. workorder number for a workorder, partnumber for a part + - Maybe all objects should have a "name" column in db be it a workorder number or part number just for consistency +- TAGS: Any search, anywhere, can include tags (internally it will post filter on tags after initial text search or if no text then will just search tags) + +- ROLES: Needs to internally be able to filter by CANREAD property of role user is in (e.g. if no rights to read client no clients returned) +- INDEX VISIBLE ID NUMBERS MUST be searchable so ensure they get put into text search with the regular text. +- OBJECT ID and/or OBJECT TYPE criteria support (AyaTypeId must be included in search index) +- PARENT / OPENABLE OBJECT: Objects that are searchable but not openable directly need to contain their parent type and id, this way we can follow a chain to the openeable object if necessary + - This is in the object, not in the searchkey as it would be inefficient + - Need parent AyaType as an ENUM ATTRIBUTE in the AyaType table for easy traversal +- CLEANUP: Generator able to cleanup index with no matching word (if possible), index with no matching typeandid +- CJK INDEX support: same as v7 +- GROUP BY: group by objectype then objectid (then created date?) +- Coding: break this into separate discrete classes, the old v7 code is very monolithic and in-elegant +- SAMPLE DATA: Need a huge amount of sample data indexed to load test it +- INDEXES: play with it and see what works best + + + +PROPOSED SCHEMA +asearchdictionary + - id (long) + - word (nvarchar 255) (indexed?) + +asearchkey + - id (long) + - wordid (fk on asearchdictionary.id) + - objectid (long id of source object) + - objecttype (AyaType as int of source object) + - inname (bool indicates the search word was in the name of the object) + + + + +REFERENCE INFO + +V7 Code: +- Word breaker: AyaBizUtils -> Break starting at line 1976 +- Insert into db: DBUtil.cs -> ProcessKeywords starting at line 423 +- Usage: Client.cs line 2104 +- SearchResultList.cs (whole class, also there is an "ri" version for some reason I forget) +- V7 DB Schema: + +CREATE TABLE [dbo].[ASEARCHDICTIONARY]( + [AID] [uniqueidentifier] NOT NULL, + [AWORD] [nvarchar](255) NOT NULL +) ON [PRIMARY] + + +CREATE TABLE [dbo].[ASEARCHKEY]( + [AWORDID] [uniqueidentifier] NOT NULL, + [ASOURCEOBJECTID] [uniqueidentifier] NOT NULL, + [ASOURCEOBJECTTYPE] [smallint] NOT NULL +) ON [PRIMARY] + diff --git a/devdocs/specs/core-seeds.txt b/devdocs/specs/core-seeds.txt new file mode 100644 index 00000000..b35486b5 --- /dev/null +++ b/devdocs/specs/core-seeds.txt @@ -0,0 +1,6 @@ +SEEDS +EXPORT READY (DATA DUMP CODED) + +REQUIREMENTS +See case 3544 + diff --git a/devdocs/specs/core-server-state.txt b/devdocs/specs/core-server-state.txt new file mode 100644 index 00000000..284f40f6 --- /dev/null +++ b/devdocs/specs/core-server-state.txt @@ -0,0 +1,30 @@ +SERVER STATE SPECS + +REQUIREMENTS + +Two parallel paths that can lead to serverstate affecting access to server: + +Closed or Open States + - If closed all routes are shut to all users with case by case exceptions: + - OPS type user exceptions: + - Login + - View long running jobs because server may be closed due to long running process they need to view for status updates + - they can fetch a license key or look at license current state (ONLY IF CLOSED DUE TO LICENSE ISSUE) + - View METRICS and log files + + +- SYSTEM_LOCK + - An independent setting outside of the regular server state that allows RAVEN to internally lock itself when License or lockout related issues like non-payment + - Acts as though the server was set to CLOSED so OPS can still go in but doesn't matter what state they set it to because the locked is a parallel in memory internal only setting + - All non-ops routes will need to see if closed and server state returns closed both if serverstate is closed or if SYSTEM_LOCK + + + + + +Some scenarios at the server require general users to be LOCKED OUT COMPLETELY but still grant limited access for operations administration, such as: + - Biz changes that need all users out such as a re-org of client data or something else business related + - Ops changes that need all users out such as upgrades, backup, restore, any mass data change such as import or export + - Emergency security issues such as hacking attempt etc + +Need a method to inform users that server *WILL* be going down within a set time frame, like 15 minutes (no need to get fancy and make it configurable) \ No newline at end of file diff --git a/devdocs/specs/core-tags.txt b/devdocs/specs/core-tags.txt new file mode 100644 index 00000000..df4419f6 --- /dev/null +++ b/devdocs/specs/core-tags.txt @@ -0,0 +1,74 @@ +# TAGS specifications + +Main case is 3373 https://rockfish.ayanova.com/default.htm#!/rfcaseEdit/3373 + +FORMAT +=-=-=- +Copied from stack overflow +tags ... + + must be no longer than 35 characters + spaces are replaced by dashes, no spaces in a tag + always converts to lower invariant culture + - (probably not this, utf-8 ok: must use the ascii character set a-z 0-9 + # - .) + + +SCHEMA +=-=-=- +Two tables: + +TAGS + - name text not null lowercase only + - id bigserial + - OwnerId + - Created +TAGMAP + - ObjectId + - ObjectType + - TagId + +INDEXES + - Initial index is on the name of the tag as that will be searched for?? + - After some research I think it's best to just make it, populate it with a great deal of test data and then test query it and see what indexes give the best performance + + + +USE-CASES +Add a tag to an object type and id, start typing and a selection list fills in to select from + - Don't allow the same tag more than once + - Create a tag if not present (rights?) +Show tags on an object +Find any type or specific type items by a set of tags to include and a set of tags to exclude (i.e. has "red, yellow" but not "green") +Search for text must allow specifying tags to refine +Reporting will require filtering sources of report data by tags + +METHODS REQUIRED IN TAG CONTROLLER + +- GET tag text by id +- GET tag id by tag text +- CREATE tag for tagging something +- REMOVE tag (and all tagmap entities) + + +- GET TAGMAPS LIST (get all object/id entities with this tag) +- CREATE TAGMAP Apply tag to an object / id +- REMOVE TAGMAP remove tag from object/id +- GET TAGS for object (name id list, main display route) + + +ROLES / RIGHTS +- Limited roles can tag stuff and remove tags as per their rights to the object type in question but can't make new tags or change existing tags +- Full roles can make new tags and can edit or delete existing tags + + +RETRIEVAL + +Will need to query tags as follows: + +ObjectType and Id list of tags (most common) +Objects with one or more tags +Objects that have a set of tags but do not have another set of tags +Objects of a certain type but any id that have a certain tag + + + diff --git a/devdocs/specs/core-testing.txt b/devdocs/specs/core-testing.txt new file mode 100644 index 00000000..e3d9f181 --- /dev/null +++ b/devdocs/specs/core-testing.txt @@ -0,0 +1,15 @@ +TESTING + +BACK-END + - Back end api is currently tested via the api and unit tests that exercise it's routes + TODO + - Some kind of continual load test of the DO server, try to break it with a lot of tests continually running for a long period of time + - A test that simulates multiple simultaneous users at the same time + + + +FRONT-END + TODO + - Integration tests that excercise the front end and ensure things appear where they are supposed to given certain tasks + - Ideally it would be able to run a set of business tasks against the UI and confirm at each point like make a rando customer and then a workorder and on + - Something that we can leave running for a long period of time to verify load and no leaks \ No newline at end of file diff --git a/devdocs/specs/core-trial-evaluation-system.txt b/devdocs/specs/core-trial-evaluation-system.txt new file mode 100644 index 00000000..d2b1e6dd --- /dev/null +++ b/devdocs/specs/core-trial-evaluation-system.txt @@ -0,0 +1,36 @@ +Trial and acquire features + +Raven will no longer be a download and try anonymously application. +There are only two ways for a non customer to trial: immediately online or self host. Both methods require the user to register to trial and the key will be automatically fulfilled. +In no case will a user be able to install a trial key without the database being erased to prevent serial trial scamming. + +LICENSED CUSTOMER ADD-ON TRIAL +Users that are already licensed and just want to try out a feature will get a normal licensed key with the feature licensed as normal but with short expiry like 30 days. +Once the feature licensed expires it's not offered in the UI anymore (or it says "A license is required to use this feature") +This way we do not need to replace the key again with a non-licensed version of the trialed add-on. + +PROSPECT ONLINE FULL TRIAL +Anyone visiting our website who is a prospective customer will be able to trial AyaNova immediately by filling out a form after which a new AyaNova will be spun up with a fixed time limit with full license registered to them but as a trial. +Inside the trial they will be able to seed data for various scenarios at will +They can purchase at any time and we will activate it into the online version already set up so they can just start working +When the time limit expires plus let's say another month the database will be automatically erased and server spun down (container deleted from docker?) + + +PROSPECT SELF HOST FULL TRIAL + +When a prospect user wants to trial self host, they will be able to download and install and it will start in unlicensed mode where they can verify it's installed and ready but there is no license so it won't allow normal ops, +The only options will be to confirm it's working properly and to request a trial key which will be sent to Rockfish automatically, generate a key automatically and install automatically. +When a trial key is first installed the database is automatically erased thus preventing them from just endlesssly requestion trial keys to keep using it for free +user can during trial pick different seed data for various scenarios at will +They can install a fully registered key at any time by purchasing from within AyaNova (at first may need to be via current shareit system) + + +FEATURES REQUIRED TO SUPPORT THIS + +- We need a management console to view the load on our server and to alert us to slowdowns so we can expand the virtual server, also we need to be able to do it in more than one datacenter as we would want local endpoints for users ultimately +- RockFish or some other application needs to be able to spin up a new server and db combo that is unique (docker container?) automatically and prune or get rid of it completely after XX days after trial has expired and it is still unlicensed +- Rockfish automatic trial key fulfillment and delivery and installation +- RAVEN built in form to request trial key and automatic fulfillment and install including db is erased any time a trial key is installed when a user already has a trial or no key installed +- RAVEN built in purchase feature + + diff --git a/devdocs/specs/core-ui-design.txt b/devdocs/specs/core-ui-design.txt new file mode 100644 index 00000000..3d6ee2a6 --- /dev/null +++ b/devdocs/specs/core-ui-design.txt @@ -0,0 +1,50 @@ +UI DESIGN DOC + +Requirements + - Responsive but favoring larger screens primarily + - Smaller screens will be able to do everything but the layout is not the primary one + - Service techs have an ipad or notebook + - Anticipate a list of things each role needs immediately in front of them and try for that + - componentized UI elements so can re-use and mix and even support customizing with user defined layout + - So for example a customer list is a self contained subset of a list widget and can be plunked down anywhere required + - This will save me time so identify the "types" of ui elements (picklist, filterable data list, entry form etc) + - Then can build specific versions of the types identified like a client list is a specialized filterable data list etc. + - If a list then it also knows which element is selected or a list of selected elements so other widgets can operate on it + - A menu or command widget that can be inserted into another widget, i.e. a part picker for a workorder part list widget or elsewhere a part is required + - This way I'm only making a few UI objects, not a new one for every single element. + - Kind of making the pallet first of all required objects then I can "paint" with them on to the UI without getting bogged down in minute details every time I make a form + - UI elements should be responsive and generic enough to work for many different use cases + - they should be rights aware and mode aware (editable, read only) enough to handle all that without recoding again and again + - the page or shell or whatever that holds the widgets should be end user customizable from a widget pallet. + - The more self contained the widget the more useful + + - Most people seem to prefer WBI over RI and the reason always seems to be RI is too simple or requires too many clicks + - So plan on the bigger screen layout being the main UI and smaller screen secondary + - I want things to be simpler and cleaner than it seems many people do so beware of that tendency + - People don't want to have to open sub screens any more than absolutely necessary + - Make sure a screen contains as much as possible to complete it on one screen + - Clean interface with good negative space but not dumbed down too much + - Pro-marketing style, stuff that makes it easier to sell + - emphasize simple fonts with good contrast + - Blue is a good colour, no purple or pastels + - DAshboard / customizable UI + - Ideally people can see as much detail as they want or remove unused ui widget elements + - So, for example on the dashboard they can customize by plunking down a client list widget, adding a "My workorders" widget for a tech + - or for a Ops person they can plunk down on their dashboard a current server status widget or active jobs widget etc + + +Graphics and themes for AyaNova + - No bitmap graphics, vector only!! + - I like material design but it will remain to be seen for the front end. + - For the manual and docs will use material theme with MKDOCS generator. + + +LOGO + - Need to standardize on a logo and stick to it from now on + - Simple and clean (script AyaNova used for RI is maybe out) + - Single colour canucks blue with green contrast if absolutely necessary (don't make it necessary) + +COLOURS + - Canucks colours of course, Blue primary and green secondary. RI already uses them, get the hex codes there. + - No indigo or pastels + diff --git a/devdocs/specs/hosting.txt b/devdocs/specs/hosting.txt new file mode 100644 index 00000000..1815ef7f --- /dev/null +++ b/devdocs/specs/hosting.txt @@ -0,0 +1,14 @@ +HOSTING + +We will be hosting users in rental mode and for trialling, we need systems in place to support that automatically, see core-trial-evaluation doc for more details + +- We need a management console to view the load on our server and to alert us to slowdowns so we can expand the virtual server, also we need to be able to do it in more than one datacenter as we would want local endpoints for users ultimately +- We need to be able to tell if a specific AyaNova server is consuming disproportionate resources. +- We need some kind of way to cap the load on a server autoamtically so they can't just have thousands of clients attempting to connect at once (i.e. they should self host if they are over a certain size / bandwith usage) +- RockFish or some other application needs to be able to spin up a new server and db combo that is unique (docker container?) automatically and prune or get rid of it completely after XX days after trial has expired and it is still unlicensed +- Rockfish automatic trial key fulfillment and delivery and installation +- RAVEN built in form to request trial key and automatic fulfillment and install including db is erased any time a trial key is installed when a user already has a trial or no key installed +- RAVEN built in purchase feature +- Rockfish built in ability to work with built in purchase to license a user and for RAVEN to check in with for new license info (i.e. monthly rental charge and license fulfillment) + + diff --git a/devdocs/specs/joyce-planning-docs-used/1_Roles.odt b/devdocs/specs/joyce-planning-docs-used/1_Roles.odt new file mode 100644 index 00000000..50532e01 Binary files /dev/null and b/devdocs/specs/joyce-planning-docs-used/1_Roles.odt differ diff --git a/devdocs/specs/joyce-planning-docs-used/2014-06-24--partsinventoryoverviewDONE.odt b/devdocs/specs/joyce-planning-docs-used/2014-06-24--partsinventoryoverviewDONE.odt new file mode 100644 index 00000000..bab7671a Binary files /dev/null and b/devdocs/specs/joyce-planning-docs-used/2014-06-24--partsinventoryoverviewDONE.odt differ diff --git a/devdocs/specs/joyce-planning-docs-used/JoyceOriginalFiles/joycev8files.zip b/devdocs/specs/joyce-planning-docs-used/JoyceOriginalFiles/joycev8files.zip new file mode 100644 index 00000000..d5a3bfa0 Binary files /dev/null and b/devdocs/specs/joyce-planning-docs-used/JoyceOriginalFiles/joycev8files.zip differ diff --git a/devdocs/specs/joyce-planning-docs-used/MainDiff - JohnsCopyOLDfrom JUNE26.odt b/devdocs/specs/joyce-planning-docs-used/MainDiff - JohnsCopyOLDfrom JUNE26.odt new file mode 100644 index 00000000..66f9719a Binary files /dev/null and b/devdocs/specs/joyce-planning-docs-used/MainDiff - JohnsCopyOLDfrom JUNE26.odt differ diff --git a/devdocs/specs/joyce-planning-docs-used/Workorder.odt b/devdocs/specs/joyce-planning-docs-used/Workorder.odt new file mode 100644 index 00000000..81da1a57 Binary files /dev/null and b/devdocs/specs/joyce-planning-docs-used/Workorder.odt differ diff --git a/devdocs/specs/marketing-sales-planning.txt b/devdocs/specs/marketing-sales-planning.txt new file mode 100644 index 00000000..ac7974b9 --- /dev/null +++ b/devdocs/specs/marketing-sales-planning.txt @@ -0,0 +1,269 @@ +MARKETING AND SALES IDEAS + + +We will have two markets: + +SAAS rental market + - User pays monthly fee to use RAVEN on our servers + - Pricing will be a factor of how many scheduleable users, accounting add-on and some kind of protection for us relating to bandwidth and number of simultaneous users + - Taking into account support and upkeep costs, hosting costs etc + - One flat price per month, as long as they keep paying they keep using and will get automatically updated and support included + - When they stop paying they can no longer use it but we won't delete the data immediately but give them time and warnings before we do + - User has the option to switch to a perpetual license by buying one in which case they can then run it self hosted or elsewhere + - This is important to marketing because it removes one of the negative preconceptions around risk using an online service (vendor bankruptcy kind of issues) + +Perpetual market with maintenance subscriptions + - User buys a perpetual license for the software itself + - specifically this means they buy service tech licenses to add in single or groups with discount per group (to be determined based on what makes us the most money and target market) + - User pays for support and updates subscription either monthly, yearly or 3 yearly with increasing discounts for each + - User can update at any time while active + + + +WHAT WE ARE SELLING + +SAAS RENTAL + The ability to use the software for month to month priced by number of techs, accounting addon and whatever we deem to be the fair price to us for server hosting plus bandwidth overages + - ideally charge them for a 20 dollar server but use a 10 dollar server kind of thing + +Perpetual: +AyaNova service technician licenses by count with sliding discount for volume purchases at time of purchase +Accounting add-on +Maintenance subscription for support and updates + - First purchase includes one year of support and udpates (or maybe a shorter time frame at a lower price? Have to research that to see what gets the most revenue) + - Subsequent years are at a rate based on whether they have the accounting add-on (one flat price) and so many dollars per service tech but sliding scale so costs less the more techs + +PHRASING +Marketing will sell the licenses with the phrase "service technician" or whatever the most universal equivalent is with a blurb at the top +explaining that a "service Tech" just means anything you schedule so could be busses or etc, but then forever afterwards just say service tech as it's less confusing + + + + + +WORKSHEET PLANNING STUFF FOLLOWS: + + +POTENTIAL NEW LICENSING SYSTEMS + +What we need: + - Absolutely need recurring revenue, no two ways about that, it's our bread and butter + - Ease of administration and ease of billing procedures + - Automated system as much as possible for both us and the client + +What our customers need + - Clear and easily explainable costs + - Ease of purchase and control over billing system + - Easy to upgrade or downgrade the level of licenses and options + - A reason to keep paying and not simply cancel a subscription when it suits them + + + +SUBSCRIPTION / RENTAL / SAAS LICENSE + - Users pay a subscription fee to be able to use the software + - Includes support and updates as long as their subscription is active + - Software does not work without a subscription at all, they are renting the software + - Quote from a link below: Prices for annual subscriptions are generally some fraction of the perpetual license alternative. + Many companies aim for a crossover point of 4 to 5 years after which the costs for the annual license begin to exceed the perpetual license fee plus the annual support costs. + So, an annual license might be priced at 40% of a perpetual license. + - Not sure about that math + + +SAAS PRICING MODELS COMMONLY USED + +FLAT RATE + - Same flat price for everything for everybody, i.e. 300 a month all in + - PROS: Easy to sell, market and explain + - CONS: rarely used less precedent, harder to extract value from different market segments, you get one basically, no flexibility for the customer + + +USAGE BASED + - Charged based on actual usage, some metric from the software, perhaps number of records or speed of access or space consumed or something + - Workorders per month or scheduled items per month would be a good metric for us + - Often used for scenarios where someone is hosting something and paying for the costs to host it, i.e. if we were hosting for people + - PROS: price scales with use, reduces barriers to use, scales for heavy users compensating for extra costs + - CONS: rarely used less precedent, harder to predict revenue, harder to predict costs for the end user, + +CAPACITY / INFRASTRUCTURE * + - Based on something that represents the size of the company + - We sort of do this now with the price per scheduled user tiers + - Normally it's done with some kind of hardware or other consideration such as the size of the server in use based on the number of CPU cores on the machine + - Idea is it's cheap for a small company and more for a larger company + +TIERED PRICING * + - Multiple packages at different price Points (MOSTLY THIS IS ABOUT VOLUME, NOT FEATURE DIFFERENCES SPECIFICALLY) + - Usually Small, medium and large (3.5 options is the average) + - Basic, Pro, Enterprise + - Targets a customer type for each tier trying to take into effect the needs at each tier with an appropriate level of service + + - PROS: + - Commonly used pricing model so easier to market and for people to understand and compare due to familiarity + - appeals to multiple buyer personas better chance of a sale + - maximize revenue (offering a single $100 package will overcharge users with a $10 willingness to pay, and undercharge users willing to spend $200.) + - clear upselling route + - CONS: + - Potentially confusing (Do not have too many tiers, maybe 3 with an out for excessive costs to us like too much bandwidth or too many support requests) + - Appeals to too many people (don't try to have a tier for every segment) + - Heavy user risk (if top people exceed expected costs no room to move them up [This would be solved by having some kind of cap and overage charges I guess]) + + +PER USER PRICING + - Exactly what it says, per POTENTIAL user of the software. 1 user one price, 2 users double the price 3 treble etc etc. + - Paid monthly or annually (discount of 12% for example) + - PROS: Simple, revenue scales with users, predictable revenue + - CONS: Hard to track, limits adoption as it can get expensive and so people might hold off too many users which increases churn due to less users tied to prodcut, rewards cheating, doesn't reflect real value; company doesn't see the difference from one user to 5 for them + + +PER ACTIVE USER PRICING + - Only charge per active user, don't pay for accounts that are unused + - Often targetted at Enterprise level customers + - Slack is a famous example + - PROS: Customers only pay for what they actually use so reduces their risk of widespread adoption as it won't cost any more if they end up not using it + - CONS: Not much value to a smaller business so extra incentive, not idea for us because it means we don't have defined recurring revenue just like the USAGE BASED model + + +PER FEATURE PRICING + - "Features" as value metric, not "users" (kind of a mix of what we do now) + - Tiers as in TIERED PRICING but by packages with sets of features more than by usage + - PROS: strong upgrade incentive, Compensate for delivery heavy (expensive to provide) features + - CONS: Difficult to get right (hard to determine which features go in which package could get ugly), Leaves a bad taste / easier to feel resentful for the customer as they know they could be getting more + + +FREEMIUM PRICING + - Tier but with a free level to hook people in. + - Pricing begins along a dimension of features, capacity or use case (you can use it to do this but not to do that; e.g. not free for commercial use) + - PROS: foot in the door, viral marketing potential for word of mouth + - CONS: Revenue killer; all revenue to support free needs to come from paid, increases churn, can devalue your core service + + + +=-=-=-=-=-=-=-=-=-=-=- + +OUR PRICING STRATEGY + - What is our pricing strategy? + - Example pricing strategies + - Penetration Pricing: "land and expand" initial unsustainably low pricing for short to medium time period to grab a market then upsell later once you have a big base + - Captive pricing: low intial price for "core" product but lot's of add-on's that are required to really use it and those generate the revenue (inkjet printers) + - Skimming pricing: start with an initial high price and slowly lower it over time (not sure this works in our market) + - Prestige pricing: maintain a high price to convey prestige or have a high prestige tier (with a rounded price $500 not $499.99) + - Free trial pricing: free month then charge to continue usage. Industry average is 50% adoption rate after free trial and 30 days is the average length + + - I want it to be easy enough to sell that we never have to hard sell anyone on it or waste time convincing them, we don't want to have to do "sales" at all + - Penetration or low pricing will give us growth and our expenses are not high so the more recurring revenue from as many sources as possible the better. + - Rather see 500 low price customers than try to fight to keep 3 high price ones, far less risk + - Possibly this means that we would take an average less profit on each sale + - I'd like to see all options available for all users as much as possible. Maybe tiers based on scheduleable resources still? But that's hard to understand for users. + + + +=-=-=-=-=- +PRICING PSYCHOLOGY + - Price Anchoring: + - set an anchor price by highlighting the most expensive package first on the marketing page so that people judge the other prices by comparison to it + - Maybe make the leftmost the most expensive or just otherwise highlight the most expensive so eyes are drawn to it first + - Always start with the most expensive price first when marketing or discussing with a customer + - Charm pricing + - People heavily judge the leftmost digit in the price subconsciously so don't sell for $400 sell for $399; people see the 3 and mentally get stuck on it + - Odd Even pricing + - People might be getting used to the .99 thing so don't end in .99, use .98 or .23 or whatever instead just keep the first digit low + - Product bundle pricing + - bundle shit together; causes people to think outcomes rather than about the individual prices + - Analysis paralysis + - Do not offer more too many options (research suggests 7 options plus or minus 2. So 5 or less is safest.) + - The biggest, most successful SaaS companies have an average of 3.5 packages available on their pricing pages. + - Center stage effect + - use the center column of multiple prices as the "most popular", or one we want to sell the most of; people will choose it more often by default all things being equal. + + + + +REQUIREMENTS + +- JSON format +- secured with hash signature +- All licensed things are in a collection, not just the add-ons + - scheduledusers, accounting etc. +- Old versions should work with new licenses but not the reverse +- Has an ID and source values like current keys, ID may become much more important in future +- ALL TRIAL KEYS ARE REGISTERED TO SOMEONE NO SUCH THING AS TWO LICENSES IN CIRCULATION WITH THE SAME NAME (i.e. no more "Unregistered trial" meaning it's a trial, every user will have a specific name to test it out) + - Rockfish issues the trial key upon first request to empty db + - Need indicator that it's a trial key / evaluation key + +- All licensed things should each have an expiry date possible to turn off that feature + - In the case of options they just stop working + - In the case of main user license everything stops working +- MUST be able to support monthly billing cycle (automatic license installation or approval so user doesn't have to install key every month) + - Rockfish should have a licensed yay or nay or new available route for RAVEN to check periodically and also users can trigger a force check +- RAVEN checks periodically for new license to fetch in line with billing cycle or expiry cycle. + - SAFE fallback if can't contact license server and allow a few misses before something kicks in, but not to allow people to use unlimited by blocking rockfish for example +- Support both perpetual license with rental maintenance subscription and rental license + - Need expiry date for maintenance so code can query if eligable for updates or support requests + - Need expiry date for entire license for rental and trial scenarios +- Support update restriction based on build date and license + +- ?? what about the add-on's expiring at the same time as the sched users. currently the expiration dates differing is a hassle, should it be still supported?? +- ?? what if we are not licensing by scheduled users anymore but by something else +- ?? need to analyze sales and determine the percentage of each level of license issued so I can group into tiers + - Current (5/29/2018) active subscription licenses: + - Single = 35 + - Up to 5 = 18 + - Up to 10 = 10 + - Up to 15 = 1 + - Up to 20 = 12 + - up to 50 = 0 + - Up to 999 = 0 + - QBI + - QBOI + - PTI + - WBI + - RI + - MBI + - OLI + - OutLookScheduleExport + + +Maybe what is needed is: + - A single license expiry date no matter what is in the license + - A single support and updates date no matter what is in the license + - A single license "type" to support tiered pricing + - Tiers: 1 user, up to 10, up to 100, custom? + - Discussed with Joyce and she made a good case for one by one license sales as most customers are small and tiers may not help as they are in the 10 and under stage anyway + so maybe no tiers at all, just licenses for "service technicians" or some more generic term and make a not early that a "service technician" is any scheduleable resource + and then cotinue to use the term service technician afterwards because it's what the majority are scheduling and it is far easier to explain. + For the rental market though we will really need to take into account bandwidth so in future there may be more types of things to track. + + + + + + + +PRODUCT LICENSE CHANGES FROM v7 + + +Still optional and purchased separately as a single Accounting interface option: +QBOI, QBI, PTI + +Possible idea: sold as "Accounting add on" and the user can use one of those of their choice and can switch which means 1 product instead of three which might make keys easier. + Possible downside is that we can't track who uses what or what we sell how much of so puts a kink in marketing or planning for deprecation or where to spend + effort, however there are other ways of addressing that. + + +//INCLUDED ADD-ON's +OLI - INCLUDED / TURNED INTO GENERIC FEATURE IMPORT / EXPORT CONTACTS and SCHEDULE TO ICAL +OutLookScheduleExport - INCLUDED / TURNED INTO GENERIC SCHED EXPORT ICAL FORMAT +Export to xls - Included with RAVEN +QuickNotification - Included with RAVEN +ImportExportDuplicate - Included with RAVEN +RI/WBI/MBI - UNNECESSARY with RAVEN + + + +===================================== + +RESEARCH SOURCES +- http://www.reprisesoftware.com/blog/2017/08/implement-a-recurring-revenue-license-model/ +- https://www.linkedin.com/pulse/20140819084845-458042-the-change-from-perpetual-to-subscription-based-software-licensing +- http://software-monetization.tmcnet.com/articles/429528-long-but-rewarding-road-a-recurring-revenue-model.htm +- How to calculate pricing in a service business: https://www.patriotsoftware.com/accounting/training/blog/how-pricing-services-strategies-models-formula/ +- SAAS pricing models: https://www.cobloom.com/blog/saas-pricing-models \ No newline at end of file diff --git a/devdocs/specs/noteable-changes-from-v7.txt b/devdocs/specs/noteable-changes-from-v7.txt new file mode 100644 index 00000000..dd4d856b --- /dev/null +++ b/devdocs/specs/noteable-changes-from-v7.txt @@ -0,0 +1,10 @@ +NOTEABLE CHANGES + + +This is a list in no order of noteable changes in RAVEN from v7. +This is for the purpose of writing an overview and change doc for RAVEN release. + + +REGIONS + - Gone, now tags + - Restrictions feature of regions gone, new roles might help for this scenario (limited roles) \ No newline at end of file diff --git a/devdocs/specs/part-category-deprecated.txt b/devdocs/specs/part-category-deprecated.txt new file mode 100644 index 00000000..699a6e9d --- /dev/null +++ b/devdocs/specs/part-category-deprecated.txt @@ -0,0 +1 @@ +Replaced with tags case 3373 diff --git a/devdocs/specs/plugin-dump.txt b/devdocs/specs/plugin-dump.txt new file mode 100644 index 00000000..d1e17f19 --- /dev/null +++ b/devdocs/specs/plugin-dump.txt @@ -0,0 +1 @@ +Consolidating all import export to one plugin, see case 3503 diff --git a/devdocs/specs/plugin-export-to-xls.txt b/devdocs/specs/plugin-export-to-xls.txt new file mode 100644 index 00000000..9913f01a --- /dev/null +++ b/devdocs/specs/plugin-export-to-xls.txt @@ -0,0 +1 @@ +Consolidated all export import to one single feature see case 3503 diff --git a/devdocs/specs/plugin-outlook-schedule.txt b/devdocs/specs/plugin-outlook-schedule.txt new file mode 100644 index 00000000..5fe4d165 --- /dev/null +++ b/devdocs/specs/plugin-outlook-schedule.txt @@ -0,0 +1,7 @@ +FIRST OF ALL CHECK TO SEE WHO IS USING THIS OR ANY OTHER FRINGE PLUGINS +THAT IS A CURRENT SUBSCRIBER + +Consolidate all outlook sched related plugins to a single way of dealing with outlook. +Also maybe different types of schedule like google calendar etc etc + +Might even be related to case 3503 the dump utility as it could dump sched items to ical format or whatever diff --git a/devdocs/specs/priorities.txt b/devdocs/specs/priorities.txt new file mode 100644 index 00000000..59c96a37 --- /dev/null +++ b/devdocs/specs/priorities.txt @@ -0,0 +1,2 @@ +Would like to replace with a tag, but they have colors. +No cases regarding this that I can find in a quick search, but I suspect there was something somewhere, will update here when found diff --git a/devdocs/specs/todo-before-release.txt b/devdocs/specs/todo-before-release.txt new file mode 100644 index 00000000..e899d922 --- /dev/null +++ b/devdocs/specs/todo-before-release.txt @@ -0,0 +1,33 @@ +TODO related to Release + + +These are items that should be handled before release, a checklist of last minute stuff + +ROCKFISH License key route stuff, make sure it's ready to make requests adn handle customers in real world scenario +RAVEN LICENSE KEY TRIAL + - make sure the various workarounds to enable trial key fetching are disabled / removed, see License.RequestTrialKey and various others + - Basically, if you remove the special CONST guid trial dbid value the rest of the code that needs to be changed should jump out due to compiler errors + +DbUtil::DBIsEmpty() + - Check that shit was updated to ensure all relevant tables are involved + + +All dependencies in AyaNova.csproj project file should be locked down to a version release + - for example "Npgsql.EntityFrameworkCore.PostgreSQL" package should be set to a determined last change version + - Then test everything, confirm all ok, and no more changes to that file until determined after release it's safe to do so + - It should be a snapshot in time. + +figure out the tiniest possible distribution and which docker container from Microsoft it will work with + - right now using sdk version on DO server because of fuckery, but ultimately it needs to be the minimal possible + +Branch / tags / repository + - Need to figure out how to branch and tag properly so can start on next version without affecting release version + - In other words snapshot the release in the repo. + - Currently it's not an issue, but maybe it should be done properly + +Remove widget route from release (or hide it) but keep in debug + +DOCUMENTATION TODO's + +Do a search for "TODO" all caps in the docs, there are things that are on hold and need to be fleshed out. +Do not release with the todo tag in there still!!!! \ No newline at end of file diff --git a/devdocs/specs/todo-original-todo-doc.txt b/devdocs/specs/todo-original-todo-doc.txt new file mode 100644 index 00000000..d6f70b73 --- /dev/null +++ b/devdocs/specs/todo-original-todo-doc.txt @@ -0,0 +1,246 @@ +# TODO original todo docs + +This was my original todo doc which I've pared down to only what I want to work on a few steps ahead. +Keeping this for reference as it has a lot of ideas about core services etc that will be useful + + + + + + A NEW START + + + +Get shit done: + +Use an agile like process and start coding ASAP with the lowest level pre-requisite stuff. + +I get stuff done faster when I see results and coding isn't bad to start early as long as it's done in the correct order and is iterated agilely + +Code tests and then code against them, don't get crazy about unit testing every little thing, just what is necessary to validate +Using an agile-like process re-iterate and continually add new stuff while testing. + + +I want an installable, testable product ready to deploy even if it only has one feature and no front end yet. + +Then starting from that base, add in all the common, shared, features first. + +Ideally we have a real product that can be installed and run as soon as possible and then build onto that and iterate, adding feature by feature. + + +1) Determine the first things to implement, ideally everything that isn't a business object requirement, bootstrap process, configuration etc. +2) Implement the core with sysop stuff, generator equivalent, db, maybe not even any user accounts yet, just the framework + - Want to be able to install as in production, start the sever, configure, view the sysop stuff make an online account and test there etc +3) Add a single simple business feature, maybe login and user creation, license etc + - Code the front end for that feature, at this point need shell and etc. + +4) Test all the above be sure about it, make sure it follows the guidelines for coding standards etc, then get into the actual features +5) Code AyaNova business features + - ** DO NOT WASTE TIME PLANNING AND DOCUMENTING EXISTING UNCHANGING FEATURES, LET AYANOVA SOURCE BE THE GUIDE FOR THAT ** + - Just document changes or new stuff only + - This is where we get into the stuff below and the biz object cases etc + - Code in order from the simplest most fundamental objects towards the most esoteric and complex multi object items + - Save the most complex objects for last so as to save time as no doubt changes will come up in other coding + - For example, there are no doubt objects that are mostly standalone and don't involve a lot of other objects, start there + + + + + + + + + + + + +****************************************************************************************************************** +****************************************************************************************************************** + OLD PLAN BELOW HERE KEPT FOR REF BUT NEW PLAN SUPERSEDES +****************************************************************************************************************** +****************************************************************************************************************** + + + + + +## FEATURE PLANNING / RE-ORG / AUDIT CASES + +- PLANNING STEP 1: Go through all AyaNova 7.x, 8.x cases and any other places there is feature stuff + - Move to RAVEN the ones that are worth keeping / looking into (snap judgements) + - Set all cases moved to Raven to priority 3 indicating unprioritized + - Stuff that will never be done for sure just close. + - Stuff that won't be done in RAVEN but might be worth keeping until the release of RAVEN before closing drop priority down to 5 + - When done there should be no more cases above priority 5 in 7.x unless they will be fixed in 7.x + +- PLANNING STEP 2: Examine RAVEN cases and re-prioritize and categorize them: + - SET THE CATEGORY COLON DENOMINATED i.e. (CLIENT, REGION, WORKORDER:TASKS:CR:NEW, UI, SECURITY etc) by prepending the title with the category. + - If it's completely new feature then the last tag should be ":NEW" + - IF it's customer requested put a :CR tag in the title + - UNPRIORITIZED + - default for items until I categorize them + - PRIORITY 3 + - MUST HAVE IN INITIAL RELEASE + - PRIORITY 1 and 2 + - flag as highest priority 1 for work on first and 2 for work on secondly + - SHOULD HAVE IN FUTURE RELEASE + - PRIORITY 4 and 5 + - flag below highest as priority 4 or 5 depending on urgency + +- PLANNING STEP 3 THE WORKORDER LAYOUT + - This is tricky so needs a whole step + - Determine new workorder layout and structure + - Don't need exact features, rough is ok + - Step 4 will get into the details of the workorder + +****************************************************************************************************************** +****************************************************************************************************************** + NOT DONE BELOW HERE +****************************************************************************************************************** +****************************************************************************************************************** + + + + +- PLANNING STEP 4 FEATURES DOCUMENTS + - Read through my own notes in CODING-STANDARDS doc and REASEARCH.TXT and SOLUTIONS.TXT + - VERY IMPORTANT stuff in there, a lot of sysops stuff and practical decision stuff + - Review it all and update any related cases / make new cases as appropriate. + + - Read through and triage Joyce's notes and docs for her v8 work. + - I've re-written them all out sorted by last edited date + - This will then be gone over more deeply as input into the later stage of feature development below + + - Go through AyaNova and all it's add-on's and put major feature areas into a list in features.txt + - Then, go through all cases and all of Joyce's v8 docs for that item / area and create a document for each feature area + - Fill in with the features and changes to that item in order of implementation + - IMPORT: Important that I also document how existing data will be imported for this feature + - If a completely new feature is determined then it needs to be in there as well (tags) + + - Find common features that are shared with objects so they can be "Interfaced" and planned for + - Tag handling, UI preferences and MRU's and user settings + - Localization stuff + - Notification + - Common menu options that are shared by objects + - Need a set of sections that deal with just this kind of thing as it will be coded FIRST. + - Common UI widgets Document every common UI widget needed and where. + - The RAVEN ui will be made up of a lot of tiny widgets that are re-used here and there, that's what I will be making so need to document that + - Essentially everything in the UI will be a bunch of widgets housed in a bunch of modules housed in a shell + - Identifying them all up front will save tremendous time in development + - For example: in lots of places we need to show a filterable list of workorders tied to the object being viewed + - Like on the client form you need to see a list of workorders for that client + - On the unit form a list of workorders for that unit + - So a WORKORDER LIST widget is one that will be re-used time and again so document that and it's features + - Then it's just a matter of snapping it into any area it's needed + - this will translate to a business object and probably a route in the api etc etc + - Search widget and results display, that kind of thing + - Identify the first object to code, ideally it should have the most common features and the least of it's own features so I can focus on the shared stuff. + + + - What I want to end up with is a set of documents one for each major feature + - In each document I want to list what is kept, what is changed and what is new + - Add points of what each feature currently does and needs + - Then go through the cases and note new and changes relevant to that object + - Consider and make decisions and rank as priority 1 (in first release) and priority 2 - subsequent release + - iterate until there is at the end what I'm after + + - This is the list I will work from to ensure everything gets done + - I want each documents items in the best order of implementation as much as possible + - THIS IS IMPORTANT: I don't want to code a feature then see that I should have done something different later + - Then I can go through it while coding as my todo list basically + - Ideally then can test as each object is ported, kind of like each object is a new feature in quick release cycle + + + +- PLANNING STEP 5 USER INTERFACE + - Design the user interface for each section above, I want to see visual design that I can work from instead of winging it. + - ?? Need a design for at least two views, desktop / tablet and phone but need to research this a bit I guess + - Doesn't need to be perfect, a rough sketch and placement is fine. + - Need to design the widgets essentially + - Need to account for each view depending on ROLE viewing it. + - Find the commonalities for the roles so there is a pre-defined levels of access for each widget that match roles + - Like a Read only limited view, a full control view, a whatever view, each type named and replicate concept to all widgets + - Widget should be aware of roles and what to allow for each of them + +- STEP 6 CODING + - Code the NFR / SHELL and fundamental / common features like tag handling etc. + - Since a widget is kind of standalone ready, maybe a widget playground to test out widgets on a blank canvas without worrying about shell stuff at first. + + + + + + + + +- HOSTING planning + - Just a light initial assesment will not be super important at first, only interested in big picture stuff that might affect coding + - What will we need for hosting? + - What is the unit of software that each customer would use + - I.E. do we have one container per customer and spin them up as required + - Apply to design + + + + +## INITIAL CODING: FRAMEWORK SKELETON ALL NON FUNCTIONAL REQUIREMENTS + +- Make a list of all the things that it should do for front and back end +- This is really the foundational skeleton of everything and is the most important step, if it takes a while so be it, it will save a fortune in time later +- The design should be frozen once this skeleton satisfies NFR +- Once this is in place then can just fill in functionally required code and objects from design +- It's also a reference point for any other new projects going forward so keep a snapshot / Subversion tag that THIS IS IT!! +- There should be no research or figuring out required after the skeleton is completed (ideally) +- Build initial framework first with all the "non functional requirement" (everything but the actual business classes) specific features in it. Take a snapshot so it can be the basis for other projects in future. +- All tools should be in place + - Development tools + - Testing tools + - Once we have something to test look into something like: https://jenkins.io/solutions/docker/ containerized? + - Unit + - Integration + - Load + - Building tools + - Once we have something to build look into something like: https://jenkins.io/solutions/docker/ containerized? + - Releasing + - versioning + - updating + - containerization + - All database types + - Installer / installation + - Management interfaces or tools +- BACK END API + - Sample routes with two api versions (0 and 1.0) + - ideally a small sample of code showing all the layers of code involved with all the NFR features desired like circuit breakers you name it + - Users and Client for starters with a list of clients, a client entry and edit and delete + + - dependency injection / loose coupling + - https://joonasw.net/view/aspnet-core-di-deep-dive + + + - Upgradeable + - Versionable + - Circuit breakers + - Performance and other metrics and logging + - All the NFR in the PROCESS and BEST PRACTICES section of my coding-standards doc +- UI Skeleton to support this backend skeleton + - All NFR that are UI level (a SPA web application) + - UI concerns that may affect back end stuff should be in the skeleton + - The guts of the UI presentation host whatever it's called without the actual specific bits to the app + - Loading + - Upgrading + - Handling errors + - talking to the api + +- TESTING + - + - Smoke test first in a limited test VM that simulates a droplet of 512mb ram, 1cpu, docker container with db etc + - Test in a DO droplet under load with lots of data and see what memory and cpu and disk resources are required and how it performs + + + + +## FR CODING + - After the above move on to functional requirements coding + - Test and deploy daily at least + + diff --git a/devdocs/specs/ui-ideas/interesting colour scheme powder blue and yellow 50s vibe.jpg b/devdocs/specs/ui-ideas/interesting colour scheme powder blue and yellow 50s vibe.jpg new file mode 100644 index 00000000..4db9b891 Binary files /dev/null and b/devdocs/specs/ui-ideas/interesting colour scheme powder blue and yellow 50s vibe.jpg differ diff --git a/devdocs/specs/unit-of-measures.txt b/devdocs/specs/unit-of-measures.txt new file mode 100644 index 00000000..35af4cc6 --- /dev/null +++ b/devdocs/specs/unit-of-measures.txt @@ -0,0 +1,2 @@ +DEPRECATED? +Still not decided yet but leaning heavily towards ditching it, see case 3433 and 2038 diff --git a/devdocs/specs/unit-service-type.txt b/devdocs/specs/unit-service-type.txt new file mode 100644 index 00000000..cdbd25b1 --- /dev/null +++ b/devdocs/specs/unit-service-type.txt @@ -0,0 +1,3 @@ +Deprecated for tag? +Didn't actually check but it seems like a likely candidate. +See case 3373 diff --git a/devdocs/specs/user-certifications.txt b/devdocs/specs/user-certifications.txt new file mode 100644 index 00000000..15911db5 --- /dev/null +++ b/devdocs/specs/user-certifications.txt @@ -0,0 +1,2 @@ +Deprecated maybe? +See case 3440, 1138 diff --git a/devdocs/specs/user-skills.txt b/devdocs/specs/user-skills.txt new file mode 100644 index 00000000..15911db5 --- /dev/null +++ b/devdocs/specs/user-skills.txt @@ -0,0 +1,2 @@ +Deprecated maybe? +See case 3440, 1138 diff --git a/devdocs/specs/user-wiki.txt b/devdocs/specs/user-wiki.txt new file mode 100644 index 00000000..91ece34a --- /dev/null +++ b/devdocs/specs/user-wiki.txt @@ -0,0 +1 @@ +WTF do we need this for? diff --git a/devdocs/specs/workorder-categories.txt b/devdocs/specs/workorder-categories.txt new file mode 100644 index 00000000..7542ab7a --- /dev/null +++ b/devdocs/specs/workorder-categories.txt @@ -0,0 +1,2 @@ +Deprecated to tags (verify this is possible)? +See case 3373 diff --git a/devdocs/specs/workorder-item-type.txt b/devdocs/specs/workorder-item-type.txt new file mode 100644 index 00000000..df1f14fa --- /dev/null +++ b/devdocs/specs/workorder-item-type.txt @@ -0,0 +1,3 @@ +Deprecated to tags? +See case 3373 +Need to confirm it will work, types don't appear to do anything and are documented as for filtering and reporting and sorting so that's a tag diff --git a/devdocs/specs/workorder.odt b/devdocs/specs/workorder.odt new file mode 100644 index 00000000..87544694 Binary files /dev/null and b/devdocs/specs/workorder.odt differ diff --git a/devdocs/todo.txt b/devdocs/todo.txt new file mode 100644 index 00000000..71618c80 --- /dev/null +++ b/devdocs/todo.txt @@ -0,0 +1,345 @@ +# TODO (J.F.C. - Just fucking code it already) + +Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNTI4MjEyNjI5IiwiZXhwIjoiMTUzMDgwNDYyOSIsImlzcyI6IkF5YU5vdmEiLCJpZCI6IjEifQ.Kst0iTQ2hLCKwEq6vkqmlSHrrmNqksxFkMM0PIRiyjA + + +## IMMEDIATE ITEMS + +CHOPPY DAY WORK ++++++++++++++++ + +Move to new server + - PLAN: Move off old server bit by bit, starting with repository, then + - BACKUP + - https://gztw1.nyc3.digitaloceanspaces.com + - Backups would be to DO Spaces + - New york is the only north american storage location but that's at least offsite + - 5 bucks a month for 250gb storage and 1tb outbound traffic + - Has a two month free trial option from the main do page, not within our droplets ui + - https://www.digitalocean.com/community/tutorials/how-to-automate-backups-digitalocean-spaces + - GIT SERVER + - https://docs.gitea.io/en-us/install-with-docker/ + - First get a private git repo working on the D.O. + - Then move all new dev to the private git server instead of the SEA repo + +CODING WORK ++++++++++++ + +Might be time to order all this to the best effectiveness if it isn't already. + +Error messages / Numbers + - All server error codes start with E1000, all API error codes start with E2000 + - Look for English text in all the messages so far and see if can be localized even crudely by google translate and do so + - Make sure error numbers have a consistent system and don't conflict, I think there are two sets of error numbers, there should only be one + - Make sure Every error has a number and that is documented in the manual + - Locale keys for error numbers?? i.e. E1000, "blah blah error 1000" + +Cleanup and sticking to the following code convention: + All names are PascalCaseOnly with the following two exceptions: + - function paramenter names are ALWAYS camelCased + - CONST values are ALL_CAPS with underlines between for spaces + +Created/Changed/Modifier/ Change / Audit log + - Flesh out and implement fully + - See cases, specs doc this has already been planned quite a bit + - Modify existing routes / objects to use the log + - Tests + - Do I add more fields to the objects to cover same as v7 (currently only created is there in widget and others) or do I make use of the changelog table for that shit?? + +Changes needed to routes?? + - http://www.talkingdotnet.com/actionresult-t-asp-net-core-2-1/ + +Overall plan for now: anything standing in the way of making the initial client shell UI needs to be done first, everything else can wait + - Localized text + - Search and search text indexing + - + +Ensure all modern best practice security is properly enabled on helloayanova.com so testing is valid + - https://en.wikipedia.org/wiki/HTTP_Strict_Transport_Security#Deployment_best_practices + +CLIENT SHELL + +Once I can make the client I need to get into that and make the shell and initial interface with enough stuff to do basic testing initially + - Make sure to see the vue.js stuff in tools and below and contemplate it fully before committing to it + - VUE was chosen some time ago and there are likely other things out now + - Look and layout, graphics, logo, anything that is shell only + - Menu system + - Help link + - search + - Login , logout + - License + - Security / rights + - See localized text / change locale + + later + - Widget CRUD and lists + - All input controls and date localization etc etc + + + +### ALL ITEMS + + + - LOCALIZED TEXT + - Localized text keys would be nice if they are understandable as is for API direct users so you don't need to be in the client to understand + what's happening + - Need a locale indepedent locale so that server errors without a corresponding user are localized to default english + - Also maybe a locale can be chosen at the server for error messages since we'll go by code numbers anyway. + - Starting to get to the point where I'll need this, i.e. error messages and logs that are part of core ops but need to be displayed in the UI + - Need to go through the api and find all the plain text messages returned and convert to locale text keys + - Ensure every error message has an error number of one kind or another and that they are not conflicted and easy to sort out if coming from server or api or etc + - Need to suck out our paid for translations and convert them into new locale text format + - DataDump?? + + + + - SEARCH TEXT + - See spec docs + + - TECH SUPPORT + - Investigate how I can look at a customers DB with RAVEN + - Data masking for dumps is a start + - special "tech support" dump with masked customer information?? + - Customer has a "key" that they can see which customer is the substituted masked one so we don't know the customer name but they can reference it themselves + - What about live looking at data through some feature? + - What would I need to look at or what information would I need? + - Be able to run a query directly and view results?? + - Be able to run a query provided + - As a fix might need to enable customer to run a provided query. + - Be able to view all the meta information about the postgres instance + - Collation, sort order, languages, anything the user can set that could fuck up RAVEN + + + - MODIFICATION / CHANGE LOG (see case 79) + + - Visible ID number generator case 3544 + + - CUSTOM FIELDS (case 3426) + + + + - Notification / generator / event of interest stuff (case 3491) BIG ONE + - Need interface, code for triggering notifications in biz objects ITriggerable :) + - Would it be more efficient to just process all notifications into the modification log regardless of subscribers + + - CHILD objects need to point to their parent and be readable in code for searching and for opening objects based on child object + - so all child objects need a typeandid of the immediate parent + - Not sure where to document this so putting it here for reference + - Required for opening a search result of a descendent that is not directly openable an + - Need parent AyaType as an ENUM ATTRIBUTE in the AyaType table for easy traversal + + + - Import V7 + - Tags - any type that is moving to tag can be coded now + + - TESTING + - Longevity test on the DO server I can have up and running see core-testing.txt doc + +CLIENT + + - WHEN HAVE CLIENT - Localization (see core-localization.md) + - Time zone stuff (case 1912 related) + - WHEN HAVE CLIENT - Layout / Form user setttings + - WHEN HAVE CLIENT - Default form filling settings handling (case 3485) + - WHEN HAVE CLIENT - Push notification to client + - PUSH / POLL notification: determine and implement a system that can send notifications to client for things like + - change of localized text (invalidate cache) + - Server shutting down (log out asap) + - business object notifications (new workorder, status change etc) + - WHEN HAVE CLIENT Report route for widget + - WHEN HAVE CLIENT - test my PickList, is it sufficient? (pageable, alpha pageable (A-D, E-G kind of thing?)) + - Case 1692 + - search by tags plus text plus maybe pageable or...??? + - WHEN HAVE CLIENT - ACTION / UI WIDGETS case 3460, 1729 UI as a collection of widgets stuff + + +- When widget is completely done, go over it and see if anything can be made easier or better before proceeding +- Generate seed data for tags +- Better to do this when the above core items are done as it touches on them +- Time zone + - This is not specced anywhere, but here, not sure where to put it at the moment, hopefully by the time I get here I will know (global settings? User settings?) + - Do not rely on the server's time zone setting, for example a docker container will be utc even if the server hosting it is pacific time + - Instead, use UTC for everything and have configurable value for timezone offset + +### NFR + + - UPDATE SWASHBUCKLE / SWAGGER to support testing file upload if not too onerous + - https://github.com/domaindrivendev/Swashbuckle/issues/280 + - http://www.talkingdotnet.com/how-to-upload-file-via-swagger-in-asp-net-core-web-api/ + + - IMPORT / EXPORT + - biz object should import from v7, make a dummy import for widgets from something I've already exported in v7 (units? something with at least a name) + - Make a route for import to upload an import file? Then it runs the import via the biz objects and the correct ordering? + - OPS Functionality?? (maybe just biz admin only since it's dealing with actual biz data) + - OPS can import ops related stuff??(notification email server settings etc) + + - BACKUP and RESTORE and COPY automatically to storage offsite + - Backup and restore data (widget, users etc) + - Close AyaNova server, erase db(optionally?? maybe user wants to combine two separate db's), restore the data + - See Discourse, they have some kind of AWS thing + - Also maybe this is handy: http://www.talkingdotnet.com/webhooks-with-asp-net-core-dropbox-and-github/ + - Download backup, upload backup file + - FTP automatically? + + + - Need api speed test route (to independently of any particular object know how fast the connection is, for choosing a host site and troubleshooting) + - Some kind of static test list that is perfectly reproducible on demand + - Maybe a set routine of items to generate and return but in a way to disambiguate between slow server and slow connection + - don't re-invent the wheel + - What to test: + - compute performance + - DB performance + - thoughput? Speed of network + + - Need some way to know if AyaNova is taking longer than it should to process requests so it can be an alert of some kind + - research how to time api avg running total or something, graph it for ops + - keep data by class of operation or tag it somehow + - Don't want it to actually slow performance + - Maybe have a benchmark time for various ops gathered during debugging tests, then hard code in that benchmark and if it takes longer then it logs it + - http://www.neekgreen.com/2017/11/06/easy-way-measure-execution-time-aspnetcore-action-method/ + - https://weblogs.asp.net/jeff/asp-net-core-middleware-to-measure-request-processing-time + + +- SSL / TLS + - Need to look into how to support this + - Look into how the 2.1 dotnet will work with ssl so I do something relatively compatible + + +- REPORTING + - NOTE TO SELF: Don't report off Biz objects, make report specific objects. Better to have a reportclient list object and a selection client list object and etc than just a single client list doing duty as a selection box filler and a reporting object + And also biz object interfaces ideas: + ITaggable, ICustomFields, ISearchable, IExportable,IBizAction, IReportable (with sub interfaces for paging, format, report name and biz object for single and list etc,report stuff), ILocaleFields?, Etc + Your welcome!🤘😎 + + +- CLIENT / UI DEVELOPMENT + - CLIENT UI "WIDGETS" ("COMPONENTS") + - Have UI testing scripts for developing UI. Scenario and then I can manually walk through it and see how ui responds to iterate from rough skeletal UI. + - Make a script for top X scenarios in the work day if each role. That way can try early rough designs with neutral expectations and reiterate until adequate. + - This way I won't design out of my ass without good input to riff off of. + - Orient express is some good shit for the shabs! + + +- Client: Start initial front end vue.js shell + - Need way to shut down clients gracefully (added value in api return? Polling [can't recall what the decision was in polling]) + +- Implement unlicensed server mode in client + - Request trial key from client, server fetches and installs + +- Implement trial mode in client + - Seed data, erase db etc + + +- AFTER APRIL 1st 2018 - Dotnet 2.1 changes I must look into: + - will be rtm this summer-ish + - some swagger and webapi affecting changes + - efcore group by and lazy loading thing, might be relevant, nto sure + - HTTPS by default + - Look into it, see if something will be so huge that I should use the beta now for dev. + + + +BUNDLING +- ONCE there is any front end code worthwhile then - Automatic build process Bundling and minification +- Parcel is coming on strong and requires supposedly zero configuration: https://parceljs.org/getting_started.html +- READ THIS: https://docs.microsoft.com/en-us/aspnet/core/client-side/using-gulp +- THEN SET IT UP +- Need automatic file copy script or whatever to copy docs to wwwroot folder somewhere so it can be served by the ayanova server +- Also need to package front end stuff for deployment as well with versioning etc, not webpack but along those lines + + + + +- Think about hostname being included with license, maybe a requirement? + - Localhost only or domain? + - Or would it be too much hassle with non-domain sites +- Fail2ban? Will we need that kind of thing incorporated into AyaNova? + - See again how it works and then look into application level ideas for that or what is smart for hardening, throttling etc + +- 2FA Two factor authentication + - How hard is this to support in AyaNova? + - What about apps like Authy? + - It might be important to enable this for ops and biz accounts? Or at least be an option? + +- LETS ENCRYPT + - https://www.humankode.com/ssl/how-to-set-up-free-ssl-certificates-from-lets-encrypt-using-docker-and-nginx + - https://weblog.west-wind.com/posts/2017/Sep/09/Configuring-LetsEncrypt-for-ASPNET-Core-and-IIS + - https://stackoverflow.com/questions/48272373/how-do-i-get-letsencrypt-working-in-asp-net-core-razor-pages + - NGINX: https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04 + - Review again if need NGINX in front of kestrel still and if so then go this route + + + + +- DO WE NEED TO BE ABLE TO admin db from within raven ops route even if can't connect to db? + - Don't want users to have to use a db admin tool for anything, so should have ability to do whatever is necessary from ops route with db + - REQUIRED OPS: See if db exists + + +DEPLOYMENT AND TESTING +- DOCKER As soon as viable make an automatic build to a docker image for testing and deployment + - https://docs.microsoft.com/en-us/dotnet/core/docker/building-net-docker-images + - remote server online testing + - Better product will come from running it as it will be used as early and often as possible + - Look into renting the cheapest server on linode or digital ocean for dev testing + - set it up to pull the latest from repo so it automatically updates (or a docker image maybe) + - possibly set up integration test that goes off the remote server +- WINDOWS Automatic installer for testing + - Need a windows test bed and regular testing on it to confirm multiplatform interoperability + - Maybe a windows installer or maybe a docker image + +- Integration test that can be pointed at any location to run a series of tests + + + +MANUAL +- Add how to use swagger UI and authentication + + +BOOTSTRAPPING AUTHENTICATION + +- Manager account can only login from localhost? - HMM...think on it + +- What if can only create new users if manager account is changed from default credentials? + - that way you start your setup with one account, change it and then it's safe to do remote work + + +- by default manager account is only one with rights to configure server or user accounts. + - Doesn't have any rights to business config, only server config and CRUD user accounts + - So at least one admin user needs to be created locallhy before it can be used remotely to set up users + - manager account cannot be changed in any way, so always has default password and login + - JWT token check must check if local when it's the manager account user id 1 + - This is so a user can't copy the creds from browser and use them remotely + - Test that shit from host +- What if can't run a browser in host for some reason??? + - need an override that does allow remote manager account + +- devise a way to bootstrap with no user accounts and a way to reset back to that + + + + +MAKE MVP + +- Has the following features: + Alpha-0 + - installer for windows and docker container + - VUE.js Front end that supports at minimum a login / logout and empty shell + - Shows server and client versions (about) + - https://vuejs.github.io/vetur/ + - swagger docs and way to view them via the api + - User manual docs + - See the tools.txt section search for vue + + Alpha-1 + - Can do some minimal config like seed data, erase db etc + - Ops interface showing status and can view log etc + + etc + + + +LONG TERM:::::: + +## MVP and iterate + diff --git a/devdocs/tools.txt b/devdocs/tools.txt new file mode 100644 index 00000000..a5ec9a64 --- /dev/null +++ b/devdocs/tools.txt @@ -0,0 +1,304 @@ +MSBUILD reference for csproj file +https://docs.microsoft.com/en-us/visualstudio/msbuild/msbuild#BKMK_ProjectFile + + +Quickly generate large files in windows: http://tweaks.com/windows/62755/quickly-generate-large-test-files-in-windows/ + +Never download another 100mb test file or waste time searching for a large file. Sometimes you need a large file fast to test data transfers or disk performance. Windows includes a utility that allows you to quickly generate a file of any size instantly. + +Open an administrative level command prompt. + +Run the following command: + +fsutil file createnew + +For example, this command will create a 1GB file called 1gb.test on my desktop: + +fsutil file createnew c:\users\steve\desktop\1gb.test 1073741824 + +The key is to input the size of the file in bytes so here are some common file sizes to save you from math: + +1 MB = 1048576 bytes + +100 MB = 104857600 bytes + +1 GB = 1073741824 bytes + +10 GB = 10737418240 bytes + +100 GB =107374182400 bytes + +1 TB = 1099511627776 bytes + +10 TB =10995116277760 bytes + +=-=-=-=-=-=-=-=-=-=- + + +After a reboot of dev machine the containers are stopped and need to be restarted on reboot with this command: +docker start dock-pg10 dock-pgadmin + +**USE PGADMIN** +Browse to localhost 5050 + +Can view the status of all containers with +docker ps -a + + + +## FRONT END + +** Best stuff for JS and development of 2017, lots of useful info here: +https://risingstars.js.org/2017/en/ + +**BROWSER CLIENT LIBRARIES** +- JQuery +- Lodash +- UI FRAMEWORK - VUE.JS ?? (maybe a framework or maybe vanilla JS) + - https://github.com/mattkol/Chromely (New alternative to Electron and supports VUE.JS) + - https://vuejs.github.io/vetur/ + - Good discussion here about general UI and also what is "infuriating" in web material design when you just want to get on with work + - Got to be careful not to make it too good looking at the expense of performance + - that being said it has other good discussion on stuff in general + + +## BUNDLING AND MINIFICATION +- https://docs.microsoft.com/en-us/aspnet/core/client-side/bundling-and-minification?tabs=visual-studio%2Caspnetcore2x +- Gulp seems best for me: https://docs.microsoft.com/en-us/aspnet/core/client-side/using-gulp + +## DEPLOYMENT + +### DEPLOY TO DIGITAL OCEAN TEST SERVER + +- PUBLISH: + - Make sure updated version number first!! + - Need to be in C:\data\code\raven\server\AyaNova\ + - Then run command: + - dotnet publish -o C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files\ -c Release +- COPY + - Use filezilla to copy files that are new up to server + - Copy to "/home/john/xfer/ayanovadocker/files" + - These two files (and any other changes that are relevant) + - C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files\AyaNova.dll + - C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files\AyaNova.pdb +- CONSOLE TO SERVER VIA PUTTY + - Bring down current containers: + - navigate to ~/xfer folder + - execute sudo docker-compose down + + - Build new image forcing it to update as it sometimes doesn't + - sudo docker-compose build --force-rm --pull + + - Run new image + - sudo docker-compose up -d + + - Restart NGINX container as it seems to lose it's mind when the AyaNova container is restarted (502 BAD GATEWAY error) + - from /docker/letsencrypt-docker-nginx/src/production run sudo docker-compose up -d + - Or just use the restartnginx.sh script in xfer at the server + + - Test + - If 502 BAD GATEWAY then AyaNova server is not up so the NGINX config bombs because it's proxying to it. + - Actually, it just happened and what needs to be done is AyaNova container needs to be running BEFORE nginx container or it seems to get stuck + - Check logs with sudo docker logs [containerID] to find out what happened + - Or in some cases (once) Digital Ocean fucked up something + + + + +### Publish command line: + +Windows 64 bit: +dotnet publish -o /home/john/Documents/raven/dist/server/win-x64/ -r win-x64 -c Release --self-contained +dotnet publish -o C:\data\code\raven\dist\server\win-x64\ -r win-x64 -c Release --self-contained + + +Linux 64 bit: + +Normal build without all the .net files (not self contained) +This is appropriate for docker based distribution since another image will contain the .net runtime: + +#### DEFAULT BUILD COMMAND +dotnet publish -o C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files\ -c Release + +(linux) +dotnet publish -o ~/Documents/raven/dist/server/linux-x64/ayanovadocker/files/ -c Release + + +Self contained (this is appropriate for non containerized distribution, but still requires some Linux native requirements - see below): +dotnet publish -o C:\data\code\raven\dist\server\linux-x64\ -r linux-x64 -c Release --self-contained +dotnet publish -o ~/Documents/raven/dist/server/linux-x64/ -r linux-x64 -c Release --self-contained + +Needed to change permissions on the AyaNova file to make it executable and also it requires these pre-requisites and probably more: +apt-get install libunwind8 +apt-get install libcurl3 + +//.net core 2.x linux native requirements +https://docs.microsoft.com/en-us/dotnet/core/linux-prerequisites?tabs=netcore2x + + + +Windows 32 bit: +dotnet publish -o /home/john/Documents/raven/dist/server/win-x86/ -r win-x86 -c Release --self-contained + +Self contained Windows 10 x64: +dotnet publish -o /home/john/Documents/raven/dist/server/win10x64/ -r win10-x64 -c Release --self-contained + +PORTABLE RID's: +win-x64 +win-x86 +linux-x64 + +//D.O. Linux +ubuntu.16.04-x64 //<--- ends up being the same size as portable linux 64 so not really necessary + + +- https://docs.microsoft.com/en-us/dotnet/core/deploying/index +- https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/index?tabs=aspnetcore2x +- https://docs.microsoft.com/en-us/dotnet/core/tools/dotnet-publish?tabs=netcore2x +- https://docs.microsoft.com/en-us/dotnet/core/rid-catalog + + +### DOCKER + + - Build containers: + - john@debian9John:~/Documents/raven/dist/docker/linux-x64$ docker-compose build + - Run it: + - :~/Documents/raven/dist/docker/linux-x64$ docker-compose up -d + - Build it in prep for running it: + - dotnet publish -o C:\data\code\raven\dist\docker\linux-x64\ayanovadocker\files\ -c Release + - john@debian9John:~/Documents/raven/server/AyaNova$ dotnet publish -o ~/Documents/raven/dist/docker/linux-x64/ayanovadocker/files -c Release + + + - OPTIONAL SAVING IMAGES (probably will never use this again but keeping for the info) + - Save image: + - docker image save -o .\image\ay-alpha2 gztw/ayanova + - Note: if you use a tag name or repo name it's preserved but if you use an image id it loses the tags + - Not compressed, can be compressed about 60% smaller + - Load image: + - docker image load -i saved_image_file_name_here + + +#### +- Running docker at our D.O. server + - run AyaNova container FIRST sudo docker-compose up -d at ~/xfer/ + - To update: + - run a publish command to publish to my local dist/linux-x64/ayanovadocker/files + - Then use Filezilla to copy up to the server at ~/xfer/ayanovadocker/files + - Optionally, update the ~/xfer/docker-compose to set a new version number for the image name ("alpha-5" etc or maybe remove the name in future) + - If necessary do a docker-compose build to rebuild + - run Nginx server: + - from /docker/letsencrypt-docker-nginx/src/production run sudo docker-compose up -d + - If necessary can switch to root with command: sudo su - + - documented here: https://www.humankode.com/ssl/how-to-set-up-free-ssl-certificates-from-lets-encrypt-using-docker-and-nginx + + +## TESTING +- DATA SEEDING: https://github.com/bchavez/Bogus (a port of faker.js) + + +**DEVELOPMENT TOOLS** +- TASK RUNNER - npm scripts +- CODE CHECK (linter) ?? +- TEST unit / integration: Mocha +- Subversion + + +### DOCKER NGINX LETS ENCRYPT CERTBOT + - https://www.humankode.com/ssl/how-to-set-up-free-ssl-certificates-from-lets-encrypt-using-docker-and-nginx + - https://github.com/humankode/letsencrypt-docker-nginx/blob/master/src/production/production.conf + + + +INITIALLY FETCH CERTIFICATES (MUST START LETSENCRYPT NGINX CONTAINER FIRST AND STOP ALL OTHERS) + +#### STAGING +sudo docker run -it --rm \ +-v /docker-volumes/etc/letsencrypt:/etc/letsencrypt \ +-v /docker-volumes/var/lib/letsencrypt:/var/lib/letsencrypt \ +-v /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site:/data/letsencrypt \ +-v "/docker-volumes/var/log/letsencrypt:/var/log/letsencrypt" \ +certbot/certbot \ +certonly --webroot \ +--email support@ayanova.com --agree-tos --no-eff-email \ +--webroot-path=/data/letsencrypt \ +--staging \ +-d helloayanova.com -d www.helloayanova.com -d v8.helloayanova.com -d test.helloayanova.com + +#### PRODUCTION +sudo docker run -it --rm \ +-v /docker-volumes/etc/letsencrypt:/etc/letsencrypt \ +-v /docker-volumes/var/lib/letsencrypt:/var/lib/letsencrypt \ +-v /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site:/data/letsencrypt \ +-v "/docker-volumes/var/log/letsencrypt:/var/log/letsencrypt" \ +certbot/certbot \ +certonly --webroot \ +--email support@ayanova.com --agree-tos --no-eff-email \ +--webroot-path=/data/letsencrypt \ +-d helloayanova.com -d www.helloayanova.com -d v8.helloayanova.com -d test.helloayanova.com + + +#### SAMPLE OUTPUT: +john@ubuntu-s-1vcpu-1gb-sfo2-01:/docker/letsencrypt-docker-nginx/src/letsencrypt$ sudo docker run -it --rm \ +> -v /docker-volumes/etc/letsencrypt:/etc/letsencrypt \ +> -v /docker-volumes/var/lib/letsencrypt:/var/lib/letsencrypt \ +> -v /docker/letsencrypt-docker-nginx/src/letsencrypt/letsencrypt-site:/data/letsencrypt \ +> -v "/docker-volumes/var/log/letsencrypt:/var/log/letsencrypt" \ +> certbot/certbot \ +> certonly --webroot \ +> --email support@ayanova.com --agree-tos --no-eff-email \ +> --webroot-path=/data/letsencrypt \ +> -d helloayanova.com -d www.helloayanova.com +Saving debug log to /var/log/letsencrypt/letsencrypt.log +Plugins selected: Authenticator webroot, Installer None +Obtaining a new certificate +Performing the following challenges: +http-01 challenge for helloayanova.com +http-01 challenge for www.helloayanova.com +Using the webroot path /data/letsencrypt for all unmatched domains. +Waiting for verification... +Cleaning up challenges + +IMPORTANT NOTES: + - Congratulations! Your certificate and chain have been saved at: + /etc/letsencrypt/live/helloayanova.com/fullchain.pem + Your key file has been saved at: + /etc/letsencrypt/live/helloayanova.com/privkey.pem + Your cert will expire on 2018-06-10. To obtain a new or tweaked + version of this certificate in the future, simply run certbot + again. To non-interactively renew *all* of your certificates, run + "certbot renew" + - Your account credentials have been saved in your Certbot + configuration directory at /etc/letsencrypt. You should make a + secure backup of this folder now. This configuration directory will + also contain certificates and private keys obtained by Certbot so + making regular backups of this folder is ideal. + - If you like Certbot, please consider supporting our work by: + + Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate + Donating to EFF: https://eff.org/donate-le + + +=-=-=-=-=-=-=-=- + + +GRAFANA / INFLUXDB / DOCKER + +Container to run the whole shebang: + +- https://github.com/philhawthorne/docker-influxdb-grafana +docker run -d \ + --name docker-influxdb-grafana \ + -p 3003:3003 \ + -p 3004:8083 \ + -p 8086:8086 \ + -p 22022:22 \ + -v /path/for/influxdb:/var/lib/influxdb \ + -v /path/for/grafana:/var/lib/grafana \ + philhawthorne/docker-influxdb-grafana:latest + + NOTE: you can leave out the paths and it works and the name is a little verbose + + Dashboard for Grafana and app.metrics: + - https://grafana.com/dashboards/2125 + + \ No newline at end of file diff --git a/dist/assets/grafana/AyaNova metrics-1525464233568.json b/dist/assets/grafana/AyaNova metrics-1525464233568.json new file mode 100644 index 00000000..7fa41cee --- /dev/null +++ b/dist/assets/grafana/AyaNova metrics-1525464233568.json @@ -0,0 +1,2959 @@ +{ + "__inputs": [], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "4.1.1" + }, + { + "type": "panel", + "id": "graph", + "name": "Graph", + "version": "" + }, + { + "type": "panel", + "id": "singlestat", + "name": "Singlestat", + "version": "" + }, + { + "type": "panel", + "id": "table", + "name": "Table", + "version": "" + } + ], + "annotations": { + "list": [] + }, + "description": "", + "editable": true, + "gnetId": 2125, + "graphTooltip": 1, + "hideControls": false, + "id": null, + "links": [], + "refresh": "5s", + "rows": [ + { + "collapse": true, + "height": -174, + "panels": [ + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "format": "rpm", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 8, + "interval": "", + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "null", + "text": "N/A", + "to": "null" + } + ], + "span": 2, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__transactions", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "rate1m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": "", + "title": "Throughput", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "N/A", + "value": "null" + } + ], + "valueName": "current" + }, + { + "cacheTimeout": null, + "colorBackground": false, + "colorValue": false, + "colors": [ + "rgba(50, 172, 45, 0.97)", + "rgba(237, 129, 40, 0.89)", + "rgba(245, 54, 54, 0.9)" + ], + "datasource": "$datasource", + "decimals": 4, + "editable": true, + "error": false, + "format": "percent", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "id": 6, + "interval": null, + "links": [], + "mappingType": 1, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "", + "text": "", + "to": "" + } + ], + "span": 2, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": true, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "targets": [ + { + "dsType": "influxdb", + "groupBy": [], + "measurement": "application.httprequests__one_minute_error_percentage_rate", + "policy": "default", + "query": "SELECT \"value\" FROM \"application.httprequests__percentage_error_requests\" WHERE $timeFilter", + "rawQuery": false, + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": "", + "title": "Error %", + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "0%", + "value": "null" + } + ], + "valueName": "current" + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 2, + "id": 13, + "interval": "$summarize", + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__active", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Active Requests", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": { + "application.httprequests__apdex.last": "#6ED0E0" + }, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "height": "", + "id": 7, + "interval": "$summarize", + "legend": { + "avg": false, + "current": false, + "max": false, + "min": false, + "show": false, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 3, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 4, + "stack": false, + "steppedLine": false, + "targets": [ + { + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__apdex", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "score" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [ + { + "colorMode": "critical", + "fill": true, + "line": true, + "op": "lt", + "value": 0.5 + }, + { + "colorMode": "warning", + "fill": true, + "line": true, + "op": "gt", + "value": 0.5 + }, + { + "colorMode": "ok", + "fill": true, + "line": true, + "op": "gt", + "value": 0.75 + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Apdex score", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "short", + "label": "apdex", + "logBase": 1, + "max": "1", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": false + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "height": "350", + "id": 1, + "interval": "$summarize", + "legend": { + "avg": false, + "current": true, + "max": false, + "min": false, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "$col", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__transactions", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "rate1m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "1 min rate" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "rate5m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "5 min rate" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "rate15m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "15 min rate" + ], + "type": "alias" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Throughput", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "rpm", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "height": "350", + "id": 2, + "interval": "$summarize", + "legend": { + "alignAsTable": false, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": false, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "$col", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__transactions", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "p95" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "95th Percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "p98" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "98th Percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "p99" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "99th Percentile" + ], + "type": "alias" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Response Time", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "height": "", + "id": 9, + "interval": "$summarize", + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": false, + "min": false, + "rightSide": true, + "show": false, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__one_minute_error_percentage_rate", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Error Rate %", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "percent", + "label": null, + "logBase": 1, + "max": "100", + "min": "0", + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "decimals": 2, + "editable": true, + "error": false, + "fill": 1, + "height": "250px", + "id": 3, + "interval": "$summarize", + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "$col", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__error_rate", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "rate1m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "1min rate" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "rate5m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "5min rate" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "rate15m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "15min rate" + ], + "type": "alias" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Error Rate", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 2, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "rpm", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "cacheTimeout": null, + "combine": { + "label": "Others", + "threshold": 0 + }, + "datasource": "$datasource", + "editable": true, + "error": false, + "fontSize": "80%", + "format": "percent", + "height": "250px", + "id": 4, + "interval": "", + "legend": { + "percentage": true, + "show": true, + "sort": null, + "sortDesc": null, + "values": true + }, + "legendType": "Right side", + "links": [], + "maxDataPoints": 3, + "nullPointMode": "connected", + "pieType": "pie", + "span": 1, + "strokeWidth": 1, + "targets": [ + { + "alias": "$tag_http_status_code", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "http_status_code" + ], + "type": "tag" + } + ], + "measurement": "application.httprequests__errors", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "sum" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "title": "Errors", + "type": "grafana-piechart-panel", + "valueName": "current" + }, + { + "columns": [ + { + "text": "Total", + "value": "total" + } + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "filterNull": true, + "fontSize": "100%", + "id": 24, + "interval": "", + "links": [], + "pageSize": 20, + "scroll": true, + "showHeader": true, + "sort": { + "col": 1, + "desc": true + }, + "span": 3, + "styles": [ + { + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 0, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "none" + } + ], + "targets": [ + { + "alias": "$tag_exception", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "exception" + ], + "type": "tag" + } + ], + "measurement": "application.httprequests__exceptions", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "title": "Uncaught Exceptions Thrown", + "transform": "timeseries_aggregations", + "type": "table" + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Overview", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "300", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "height": "350", + "id": 16, + "interval": "$summarize", + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "sort": "current", + "sortDesc": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": true, + "steppedLine": false, + "targets": [ + { + "alias": "$tag_route", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "route" + ], + "type": "tag" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__transactions_per_endpoint", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "rate1m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Throughput / Endpoint", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 2, + "value_type": "individual" + }, + "transparent": false, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "rpm", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "height": "350", + "id": 17, + "interval": "$summarize", + "legend": { + "alignAsTable": true, + "avg": false, + "current": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": false + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "$tag_route", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "route" + ], + "type": "tag" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__transactions_per_endpoint", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "p95" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "95th Percentile" + ], + "type": "alias" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Response Time / Endpoint", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "ms", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "columns": [ + { + "text": "Current", + "value": "current" + } + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "filterNull": false, + "fontSize": "100%", + "id": 10, + "interval": "", + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 1, + "desc": true + }, + "span": 6, + "styles": [ + { + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "ms" + } + ], + "targets": [ + { + "alias": "$tag_route", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "route" + ], + "type": "tag" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__transactions_per_endpoint", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "p95" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "title": "Response Times / Endpoint", + "transform": "timeseries_aggregations", + "type": "table" + }, + { + "columns": [ + { + "text": "Current", + "value": "current" + } + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "filterNull": false, + "fontSize": "100%", + "id": 12, + "interval": "", + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 1, + "desc": true + }, + "span": 6, + "styles": [ + { + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 2, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "rpm" + } + ], + "targets": [ + { + "alias": "$tag_route", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "route" + ], + "type": "tag" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__transactions_per_endpoint", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "rate1m" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "title": "Throughput / Endpoint", + "transform": "timeseries_aggregations", + "type": "table" + }, + { + "columns": [ + { + "text": "Current", + "value": "current" + } + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "filterNull": false, + "fontSize": "100%", + "id": 11, + "interval": "", + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": null, + "desc": false + }, + "span": 6, + "styles": [ + { + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 0, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "percent" + } + ], + "targets": [ + { + "alias": "$tag_route", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "route" + ], + "type": "tag" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__one_minute_error_percentage_rate_per_endpoint", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "title": "Error Request Percentage / Endpoint", + "transform": "timeseries_aggregations", + "type": "table" + }, + { + "columns": [ + { + "text": "Total", + "value": "total" + } + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "filterNull": false, + "fontSize": "100%", + "id": 25, + "interval": "", + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 1, + "desc": true + }, + "span": 6, + "styles": [ + { + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "colorMode": null, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 0, + "pattern": "/.*/", + "thresholds": [], + "type": "number", + "unit": "none" + } + ], + "targets": [ + { + "alias": "$tag_route [$tag_exception]", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "route" + ], + "type": "tag" + }, + { + "params": [ + "exception" + ], + "type": "tag" + } + ], + "measurement": "application.httprequests__exceptions", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "title": "Uncaught Exceptions Thrown / Endpoint", + "transform": "timeseries_aggregations", + "type": "table" + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Endpoints", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "250", + "panels": [ + { + "columns": [ + { + "text": "Current", + "value": "current" + } + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "filterNull": false, + "fontSize": "100%", + "hideTimeOverride": true, + "id": 22, + "interval": "", + "links": [], + "pageSize": null, + "scroll": true, + "showHeader": true, + "sort": { + "col": 0, + "desc": true + }, + "span": 9, + "styles": [ + { + "dateFormat": "YYYY-MM-DD HH:mm:ss", + "pattern": "Time", + "type": "date" + }, + { + "colorMode": "row", + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "decimals": 1, + "pattern": "/.*/", + "thresholds": [ + "0.5", + "1" + ], + "type": "number", + "unit": "short" + } + ], + "targets": [ + { + "alias": "$tag_health_check_name", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "health_check_name" + ], + "type": "tag" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.health__results", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "timeFrom": null, + "title": "Results", + "transform": "timeseries_aggregations", + "transparent": true, + "type": "table" + }, + { + "cacheTimeout": null, + "colorBackground": true, + "colorValue": false, + "colors": [ + "rgba(245, 54, 54, 0.9)", + "rgba(237, 129, 40, 0.89)", + "rgba(50, 172, 45, 0.97)" + ], + "datasource": "$datasource", + "editable": true, + "error": false, + "format": "none", + "gauge": { + "maxValue": 100, + "minValue": 0, + "show": false, + "thresholdLabels": false, + "thresholdMarkers": true + }, + "hideTimeOverride": true, + "id": 19, + "interval": null, + "links": [ + { + "type": "dashboard" + } + ], + "mappingType": 2, + "mappingTypes": [ + { + "name": "value to text", + "value": 1 + }, + { + "name": "range to text", + "value": 2 + } + ], + "maxDataPoints": 100, + "nullPointMode": "connected", + "nullText": null, + "postfix": "", + "postfixFontSize": "50%", + "prefix": "", + "prefixFontSize": "50%", + "rangeMaps": [ + { + "from": "0", + "text": "Unhealthy", + "to": "0.49" + }, + { + "from": "0.5", + "text": "Degraded", + "to": "0.9" + }, + { + "from": "1.0", + "text": "Healthy", + "to": "2.0" + } + ], + "span": 3, + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": false + }, + "targets": [ + { + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.health__score", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "value" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + } + ] + ], + "tags": [ + { + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": "0.5,1", + "timeFrom": null, + "title": "", + "transparent": true, + "type": "singlestat", + "valueFontSize": "80%", + "valueMaps": [ + { + "op": "=", + "text": "Unhealthy", + "value": "0" + }, + { + "op": "=", + "text": "Degraded", + "value": "0.5" + }, + { + "op": "=", + "text": "Healthy", + "value": "1.0" + } + ], + "valueName": "current" + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "Health", + "titleSize": "h6" + }, + { + "collapse": false, + "height": "300", + "panels": [ + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "id": 14, + "interval": "$summarize", + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "hideEmpty": false, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "$col", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__post_size", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "p95" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "95th percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "p98" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "98th percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "p99" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "99th percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "last" + ], + "type": "field" + }, + { + "params": [], + "type": "median" + }, + { + "params": [ + "median" + ], + "type": "alias" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Post Request Size", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "decbytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + }, + { + "aliasColors": {}, + "bars": false, + "datasource": "$datasource", + "editable": true, + "error": false, + "fill": 1, + "id": 15, + "interval": "$summarize", + "legend": { + "alignAsTable": true, + "avg": false, + "current": true, + "max": false, + "min": false, + "rightSide": true, + "show": true, + "total": false, + "values": true + }, + "lines": true, + "linewidth": 1, + "links": [], + "nullPointMode": "connected", + "percentage": false, + "pointradius": 5, + "points": false, + "renderer": "flot", + "seriesOverrides": [], + "span": 6, + "stack": false, + "steppedLine": false, + "targets": [ + { + "alias": "$col", + "dsType": "influxdb", + "groupBy": [ + { + "params": [ + "$interval" + ], + "type": "time" + }, + { + "params": [ + "null" + ], + "type": "fill" + } + ], + "measurement": "application.httprequests__put_size", + "policy": "default", + "refId": "A", + "resultFormat": "time_series", + "select": [ + [ + { + "params": [ + "p95" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "95th percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "p98" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "98th percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "p99" + ], + "type": "field" + }, + { + "params": [], + "type": "last" + }, + { + "params": [ + "99th percentile" + ], + "type": "alias" + } + ], + [ + { + "params": [ + "median" + ], + "type": "field" + }, + { + "params": [], + "type": "median" + }, + { + "params": [ + "median" + ], + "type": "alias" + } + ] + ], + "tags": [ + { + "key": "app", + "operator": "=~", + "value": "/^$application$/" + }, + { + "condition": "AND", + "key": "env", + "operator": "=~", + "value": "/^$environment$/" + }, + { + "condition": "AND", + "key": "server", + "operator": "=~", + "value": "/^$server$/" + } + ] + } + ], + "thresholds": [], + "timeFrom": null, + "timeShift": null, + "title": "Put Request Size", + "tooltip": { + "msResolution": false, + "shared": true, + "sort": 0, + "value_type": "individual" + }, + "type": "graph", + "xaxis": { + "mode": "time", + "name": null, + "show": true, + "values": [] + }, + "yaxes": [ + { + "format": "bytes", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + }, + { + "format": "short", + "label": null, + "logBase": 1, + "max": null, + "min": null, + "show": true + } + ] + } + ], + "repeat": null, + "repeatIteration": null, + "repeatRowId": null, + "showTitle": true, + "title": "PUT & POST Request Size", + "titleSize": "h6" + } + ], + "schemaVersion": 14, + "style": "dark", + "tags": [ + "influxdb" + ], + "templating": { + "list": [ + { + "allValue": null, + "current": {}, + "datasource": "$datasource", + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "environment", + "options": [], + "query": "SHOW TAG VALUES WITH KEY = \"env\"", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": null, + "tags": [], + "tagsQuery": null, + "type": "query", + "useTags": false + }, + { + "allValue": null, + "current": {}, + "datasource": "$datasource", + "hide": 0, + "includeAll": false, + "label": null, + "multi": false, + "name": "application", + "options": [], + "query": "SHOW TAG VALUES WITH KEY = \"app\"", + "refresh": 1, + "regex": "", + "sort": 1, + "tagValuesQuery": null, + "tags": [], + "tagsQuery": null, + "type": "query", + "useTags": false + }, + { + "current": { + "text": "AyaNovaMetrics", + "value": "AyaNovaMetrics" + }, + "hide": 0, + "label": null, + "name": "datasource", + "options": [], + "query": "influxdb", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "auto": false, + "auto_count": 30, + "auto_min": "10s", + "current": { + "text": "5s", + "value": "5s" + }, + "hide": 0, + "label": null, + "name": "summarize", + "options": [ + { + "selected": true, + "text": "5s", + "value": "5s" + }, + { + "selected": false, + "text": "10s", + "value": "10s" + }, + { + "selected": false, + "text": "30s", + "value": "30s" + }, + { + "selected": false, + "text": "1m", + "value": "1m" + }, + { + "selected": false, + "text": "10m", + "value": "10m" + }, + { + "selected": false, + "text": "30m", + "value": "30m" + }, + { + "selected": false, + "text": "1h", + "value": "1h" + }, + { + "selected": false, + "text": "6h", + "value": "6h" + }, + { + "selected": false, + "text": "12h", + "value": "12h" + }, + { + "selected": false, + "text": "1d", + "value": "1d" + }, + { + "selected": false, + "text": "7d", + "value": "7d" + }, + { + "selected": false, + "text": "14d", + "value": "14d" + }, + { + "selected": false, + "text": "30d", + "value": "30d" + } + ], + "query": "5s,10s,30s,1m,10m,30m,1h,6h,12h,1d,7d,14d,30d", + "refresh": 2, + "type": "interval" + }, + { + "allValue": null, + "current": {}, + "datasource": "$datasource", + "hide": 0, + "includeAll": true, + "label": null, + "multi": true, + "name": "server", + "options": [], + "query": "SHOW TAG VALUES WITH KEY = \"server\"", + "refresh": 1, + "regex": "", + "sort": 0, + "tagValuesQuery": "", + "tags": [], + "tagsQuery": "", + "type": "query", + "useTags": false + } + ] + }, + "time": { + "from": "now-5m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "browser", + "title": "AyaNova metrics", + "version": 2 +} \ No newline at end of file diff --git a/dist/docker/linux-x64/ayanovadocker/dockerfile b/dist/docker/linux-x64/ayanovadocker/dockerfile new file mode 100644 index 00000000..20ee1bd7 --- /dev/null +++ b/dist/docker/linux-x64/ayanovadocker/dockerfile @@ -0,0 +1,4 @@ +FROM microsoft/dotnet:2.1-aspnetcore-runtime +WORKDIR /app +COPY ./files . +ENTRYPOINT ["dotnet", "AyaNova.dll"] \ No newline at end of file diff --git a/dist/docker/linux-x64/docker-compose.yml b/dist/docker/linux-x64/docker-compose.yml new file mode 100644 index 00000000..d0f5a0bb --- /dev/null +++ b/dist/docker/linux-x64/docker-compose.yml @@ -0,0 +1,52 @@ +version: '2' + +services: + + metrics: + image: philhawthorne/docker-influxdb-grafana:latest + restart: always + ports: + - "3003:3003" + - "3004:8083" + - "8086:8086" + - "22022:22" + + postgresserver: + image: postgres:alpine + restart: always + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: letmein + volumes: + - /var/lib/ayanova/db:/var/lib/postgresql/data + + ayanova: + image: gztw/ayanova:v8.0.0 + restart: always + ports: + - 7575:7575 + volumes: + - /var/lib:/var/lib + environment: + AYANOVA_USE_URLS: http://*:7575 + AYANOVA_DB_CONNECTION: User ID=postgres;Password=letmein;Host=postgresserver;Port=5432;Database=AyaNova;Pooling=true; + AYANOVA_FOLDER_USER_FILES: /var/lib/ayanova/files/user + AYANOVA_FOLDER_BACKUP_FILES: /var/lib/ayanova/files/backup + AYANOVA_LOG_PATH: /var/lib/ayanova + AYANOVA_LOG_LEVEL: Info + AYANOVA_METRICS_USE_INFLUXDB: "true" + AYANOVA_METRICS_INFLUXDB_BASEURL: http://metrics:8086 +# AYANOVA_PERMANENTLY_ERASE_DATABASE: "true" + build: + context: ./ayanovadocker + dockerfile: Dockerfile + links: + - postgresserver + depends_on: + - "postgresserver" + +networks: + default: + external: + name: docker-network diff --git a/dist/docker/linux-x64/docker-compose.yml.original.b4.metrics b/dist/docker/linux-x64/docker-compose.yml.original.b4.metrics new file mode 100644 index 00000000..06dd6c2f --- /dev/null +++ b/dist/docker/linux-x64/docker-compose.yml.original.b4.metrics @@ -0,0 +1,41 @@ +version: '2' + +services: + + postgresserver: + image: postgres:alpine + restart: always + ports: + - 5432:5432 + environment: + POSTGRES_PASSWORD: letmein + volumes: + - /var/lib/ayanova/db:/var/lib/postgresql/data + + ayanova: + image: gztw/ayanova:v8.0.0 + restart: always + ports: + - 7575:7575 + volumes: + - /var/lib:/var/lib + environment: + AYANOVA_USE_URLS: http://*:7575 + AYANOVA_DB_CONNECTION: User ID=postgres;Password=letmein;Host=postgresserver;Port=5432;Database=AyaNova;Pooling=true; + AYANOVA_FOLDER_USER_FILES: /var/lib/ayanova/files/user + AYANOVA_FOLDER_BACKUP_FILES: /var/lib/ayanova/files/backup + AYANOVA_LOG_PATH: /var/lib/ayanova + AYANOVA_LOG_LEVEL: Info +# AYANOVA_PERMANENTLY_ERASE_DATABASE: "true" + build: + context: ./ayanovadocker + dockerfile: Dockerfile + links: + - postgresserver + depends_on: + - "postgresserver" + +networks: + default: + external: + name: docker-network diff --git a/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/docker-compose.yml b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/docker-compose.yml new file mode 100644 index 00000000..9d4f9c31 --- /dev/null +++ b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.1' + +services: + + letsencrypt-nginx-container: + container_name: 'letsencrypt-nginx-container' + image: nginx:latest + ports: + - "80:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf + - ./letsencrypt-site:/usr/share/nginx/html + networks: + - docker-network + +networks: + docker-network: + driver: bridge diff --git a/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/letsencrypt-site/index.html b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/letsencrypt-site/index.html new file mode 100644 index 00000000..255923ff --- /dev/null +++ b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/letsencrypt-site/index.html @@ -0,0 +1,14 @@ + + + + + Let's Encrypt First Time Cert Issue Site + + +

Hello world

+

+ This is the temporary site that will only be used for the very first time SSL certificates are issued by Let's Encrypt's + certbot. +

+ + diff --git a/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/nginx.conf b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/nginx.conf new file mode 100644 index 00000000..d4dc46d0 --- /dev/null +++ b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/letsencrypt/nginx.conf @@ -0,0 +1,13 @@ +server { + listen 80; + listen [::]:80; + server_name helloayanova.com www.helloayanova.com v8.helloayanova.com test.helloayanova.com; + + location ~ /.well-known/acme-challenge { + allow all; + root /usr/share/nginx/html; + } + + root /usr/share/nginx/html; + index index.html; +} diff --git a/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/dh-param/dhparam-2048.pem b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/dh-param/dhparam-2048.pem new file mode 100644 index 00000000..27eabc38 --- /dev/null +++ b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/dh-param/dhparam-2048.pem @@ -0,0 +1,8 @@ +-----BEGIN DH PARAMETERS----- +MIIBCAKCAQEA2wcsrWmfQbGC0V8eW14YPtYA1jt2dNeqV6B7Z/w0GnrwjL+xuYhG +LzDhQuJvhEsDFCd//roBXWOFOZdAR0otkcxaQ+AaP0z/0UsC8NWGnM1G6q4fBju/ +y9e+dqjybyHIX10FtTj/gKV8lBcWJIw7cMmlAShj6xfd1zPPehNswLiRrWHusL/E +5GkV/x4U76KbViqqTqrV5J6dmnxaNk4s8AphGvqeu/UrewjVf8C+fl6hljICUayJ +WzHd5Ss/CASPRk91nnhcP9r3XZNyuPkyxmJrlZVElsC94T5Chnth+uix4TpBV/2P +0Ax8sCLPVlw9Op7Bu7fJ+QJ5gbVk9n93mwIBAg== +-----END DH PARAMETERS----- diff --git a/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/docker-compose.yml b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/docker-compose.yml new file mode 100644 index 00000000..6f86fa98 --- /dev/null +++ b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/docker-compose.yml @@ -0,0 +1,22 @@ +version: '3.1' + +services: + + production-nginx-container: + container_name: 'production-nginx-container' + image: nginx:latest + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./production.conf:/etc/nginx/conf.d/default.conf + - ./production-site:/usr/share/nginx/html + - ./dh-param/dhparam-2048.pem:/etc/ssl/certs/dhparam-2048.pem + - /docker-volumes/etc/letsencrypt/live/helloayanova.com/fullchain.pem:/etc/letsencrypt/live/helloayanova.com/fullchain.pem + - /docker-volumes/etc/letsencrypt/live/helloayanova.com/privkey.pem:/etc/letsencrypt/live/helloayanova.com/privkey.pem + +networks: + default: + external: + name: docker-network diff --git a/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/production-site/index.html b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/production-site/index.html new file mode 100644 index 00000000..4184f227 --- /dev/null +++ b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/production-site/index.html @@ -0,0 +1,13 @@ + + + + + HelloAyaNova + + +

Hello AyaNova

+

+ Test site +

+ + diff --git a/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/production.conf b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/production.conf new file mode 100644 index 00000000..c62769a7 --- /dev/null +++ b/dist/docker/linux-x64/host/docker-nginx-ayanova-sample-config/production/production.conf @@ -0,0 +1,142 @@ +server { + listen 80; + listen [::]:80; + server_name helloayanova.com www.helloayanova.com; + location ^~ /.well-known/acme-challenge { + root /usr/share/nginx/html; + default_type text/plain; + allow all; + } + location / { + rewrite ^ https://$host$request_uri? permanent; + } +} +#https://helloayanova.com +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name helloayanova.com; + server_tokens off; + ssl_certificate /etc/letsencrypt/live/helloayanova.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/helloayanova.com/privkey.pem; + ssl_buffer_size 8k; + ssl_dhparam /etc/ssl/certs/dhparam-2048.pem; + ssl_protocols TLSv1.2 TLSv1.1 TLSv1; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5; + ssl_ecdh_curve secp384r1; + ssl_session_tickets off; + # OCSP stapling + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8; + location ^~ /.well-known/acme-challenge { + root /usr/share/nginx/html; + default_type text/plain; + allow all; + } + return 301 https://www.helloayanova.com$request_uri; +} +#https://www.helloayanova.com +#This is the "web" server for static files outside of AyaNova app server +server { + server_name www.helloayanova.com; + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_tokens off; + ssl on; + ssl_buffer_size 8k; + ssl_dhparam /etc/ssl/certs/dhparam-2048.pem; + ssl_protocols TLSv1.2 TLSv1.1 TLSv1; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5; + ssl_ecdh_curve secp384r1; + ssl_session_tickets off; + # OCSP stapling + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 8.8.4.4; + ssl_certificate /etc/letsencrypt/live/helloayanova.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/helloayanova.com/privkey.pem; + location ^~ /.well-known/acme-challenge { + root /usr/share/nginx/html; + default_type text/plain; + allow all; + } + + + location / { + #security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + #CSP + add_header Content-Security-Policy "frame-src 'self'; default-src 'self'; script-src 'self' 'unsafe-inline' https://maxcdn.bootstrapcdn.com https://ajax.googleapis.com; img-src 'self'; style-src 'self' https://maxcdn.bootstrapcdn.com; font-src 'self' data: https://maxcdn.bootstrapcdn.com; form-action 'self'; upgrade-insecure-requests;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + } + + + root /usr/share/nginx/html; + index index.html; +} + +#https://v8.helloayanova.com, https://test.helloayanova.com helloayanova +server { + server_name test.helloayanova.com v8.helloayanova.com; + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_tokens off; + ssl on; + ssl_buffer_size 8k; + ssl_dhparam /etc/ssl/certs/dhparam-2048.pem; + ssl_protocols TLSv1.2 TLSv1.1 TLSv1; + ssl_prefer_server_ciphers on; + ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DH+3DES:!ADH:!AECDH:!MD5; + ssl_ecdh_curve secp384r1; + ssl_session_tickets off; + # OCSP stapling + ssl_stapling on; + ssl_stapling_verify on; + resolver 8.8.8.8 8.8.4.4; + ssl_certificate /etc/letsencrypt/live/helloayanova.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/helloayanova.com/privkey.pem; + location ^~ /.well-known/acme-challenge { + root /usr/share/nginx/html; + default_type text/plain; + allow all; + } + + location / { + #security headers + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; + add_header X-XSS-Protection "1; mode=block" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + + #CSP + #https://developers.google.com/web/fundamentals/security/csp/ + add_header Content-Security-Policy "frame-src 'self'; default-src 'self'; script-src 'self' 'unsafe-inline' https://apis.google.com; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com/; font-src 'self' https://fonts.googleapis.com/ https://fonts.gstatic.com; form-action 'self'; upgrade-insecure-requests;" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + #This is "ayanova" because it's the docker network and port + proxy_pass http://ayanova:7575; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection ""; + proxy_set_header Host $http_host; + proxy_cache_bypass $http_upgrade; + + #These timeouts are only required for large trial data generation which should be re-coded to start the process and return immediately + #AS of alpha-4 large data generation on D.O. takes 1'04'' so setting these to 3 minutes as a safe margin + + proxy_connect_timeout 180; + proxy_send_timeout 180; + proxy_read_timeout 180; + send_timeout 180; + + + } + + +} diff --git a/dist/docker/linux-x64/restartnginx.sh b/dist/docker/linux-x64/restartnginx.sh new file mode 100644 index 00000000..66560c61 --- /dev/null +++ b/dist/docker/linux-x64/restartnginx.sh @@ -0,0 +1,6 @@ +#!/bin/bash +cd /docker/letsencrypt-docker-nginx/src/production +docker-compose down +docker-compose up -d +#docker start dock-pg10 dock-pgadmin +#/docker/letsencrypt-docker-nginx/src/production run sudo docker-compose up -d diff --git a/docs/8.0/ayanova/docs/_placeholder.md b/docs/8.0/ayanova/docs/_placeholder.md new file mode 100644 index 00000000..917c948c --- /dev/null +++ b/docs/8.0/ayanova/docs/_placeholder.md @@ -0,0 +1,254 @@ +# Placeholder + +This is a placeholder page for sections that are not written yet + +#STANDARDS FOR AYANOVA DOCS + +All one or two # headings are all capse, three or more #'s are regular sentence case. + + + +## Body copy + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras arcu libero, +mollis sed massa vel, *ornare viverra ex*. Mauris a ullamcorper lacus. Nullam +urna elit, malesuada eget finibus ut, ullamcorper ac tortor. Vestibulum sodales +pulvinar nisl, pharetra aliquet est. Quisque volutpat erat ac nisi accumsan +tempor. + +**Sed suscipit**, orci non pretium pretium, quam mi gravida metus, vel +venenatis justo est condimentum diam. Maecenas non ornare justo. Nam a ipsum +eros. [Nulla aliquam](/) orci sit amet nisl posuere malesuada. Proin aliquet +nulla velit, quis ultricies orci feugiat et. `Ut tincidunt sollicitudin` +tincidunt. Aenean ullamcorper sit amet nulla at interdum. + +## Headings + +### The 3rd level + +#### The 4th level + +##### The 5th level + +###### The 6th level + +## Headings with secondary text + +### The 3rd level with secondary text + +#### The 4th level with secondary text + +##### The 5th level with secondary text + +###### The 6th level with secondary text + +## Blockquotes + +> Morbi eget dapibus felis. Vivamus venenatis porttitor tortor sit amet rutrum. + Pellentesque aliquet quam enim, eu volutpat urna rutrum a. Nam vehicula nunc + mauris, a ultricies libero efficitur sed. *Class aptent* taciti sociosqu ad + litora torquent per conubia nostra, per inceptos himenaeos. Sed molestie + imperdiet consectetur. + +### Blockquote nesting + +> **Sed aliquet**, neque at rutrum mollis, neque nisi tincidunt nibh, vitae + faucibus lacus nunc at lacus. Nunc scelerisque, quam id cursus sodales, lorem + [libero fermentum](/) urna, ut efficitur elit ligula et nunc. + +> > Mauris dictum mi lacus, sit amet pellentesque urna vehicula fringilla. + Ut sit amet placerat ante. Proin sed elementum nulla. Nunc vitae sem odio. + Suspendisse ac eros arcu. Vivamus orci erat, volutpat a tempor et, rutrum. + eu odio. + +> > > `Suspendisse rutrum facilisis risus`, eu posuere neque commodo a. + Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed nec leo + bibendum, sodales mauris ut, tincidunt massa. + +### Other content blocks + +> Vestibulum vitae orci quis ante viverra ultricies ut eget turpis. Sed eu + lectus dapibus, eleifend nulla varius, lobortis turpis. In ac hendrerit nisl, + sit amet laoreet nibh. + ``` js hl_lines="8" + var _extends = function(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var key in source) { + target[key] = source[key]; + } + } + return target; + }; + ``` + + > > Praesent at `:::js return target`, sodales nibh vel, tempor felis. Fusce + vel lacinia lacus. Suspendisse rhoncus nunc non nisi iaculis ultrices. + Donec consectetur mauris non neque imperdiet, eget volutpat libero. + +## Lists + +### Unordered lists + +* Sed sagittis eleifend rutrum. Donec vitae suscipit est. Nullam tempus tellus + non sem sollicitudin, quis rutrum leo facilisis. Nulla tempor lobortis orci, + at elementum urna sodales vitae. In in vehicula nulla, quis ornare libero. + + * Duis mollis est eget nibh volutpat, fermentum aliquet dui mollis. + * Nam vulputate tincidunt fringilla. + * Nullam dignissim ultrices urna non auctor. + +* Aliquam metus eros, pretium sed nulla venenatis, faucibus auctor ex. Proin ut + eros sed sapien ullamcorper consequat. Nunc ligula ante, fringilla at aliquam + ac, aliquet sed mauris. + +* Nulla et rhoncus turpis. Mauris ultricies elementum leo. Duis efficitur + accumsan nibh eu mattis. Vivamus tempus velit eros, porttitor placerat nibh + lacinia sed. Aenean in finibus diam. + +### Ordered lists + +1. Integer vehicula feugiat magna, a mollis tellus. Nam mollis ex ante, quis + elementum eros tempor rutrum. Aenean efficitur lobortis lacinia. Nulla + consectetur feugiat sodales. + +2. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur + ridiculus mus. Aliquam ornare feugiat quam et egestas. Nunc id erat et quam + pellentesque lacinia eu vel odio. + + 1. Vivamus venenatis porttitor tortor sit amet rutrum. Pellentesque aliquet + quam enim, eu volutpat urna rutrum a. Nam vehicula nunc mauris, a + ultricies libero efficitur sed. + + 1. Mauris dictum mi lacus + 2. Ut sit amet placerat ante + 3. Suspendisse ac eros arcu + + 2. Morbi eget dapibus felis. Vivamus venenatis porttitor tortor sit amet + rutrum. Pellentesque aliquet quam enim, eu volutpat urna rutrum a. Sed + aliquet, neque at rutrum mollis, neque nisi tincidunt nibh. + + 3. Pellentesque eget `:::js var _extends` ornare tellus, ut gravida mi. + ``` js hl_lines="1" + var _extends = function(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var key in source) { + target[key] = source[key]; + } + } + return target; + }; + ``` + +3. Vivamus id mi enim. Integer id turpis sapien. Ut condimentum lobortis + sagittis. Aliquam purus tellus, faucibus eget urna at, iaculis venenatis + nulla. Vivamus a pharetra leo. + +### Definition lists + +Lorem ipsum dolor sit amet + +: Sed sagittis eleifend rutrum. Donec vitae suscipit est. Nullam tempus + tellus non sem sollicitudin, quis rutrum leo facilisis. Nulla tempor + lobortis orci, at elementum urna sodales vitae. In in vehicula nulla. + + Duis mollis est eget nibh volutpat, fermentum aliquet dui mollis. + Nam vulputate tincidunt fringilla. + Nullam dignissim ultrices urna non auctor. + +Cras arcu libero + +: Aliquam metus eros, pretium sed nulla venenatis, faucibus auctor ex. Proin + ut eros sed sapien ullamcorper consequat. Nunc ligula ante, fringilla at + aliquam ac, aliquet sed mauris. + +## Code blocks + +### Inline + +Morbi eget `dapibus felis`. Vivamus *`venenatis porttitor`* tortor sit amet +rutrum. Class aptent taciti sociosqu ad litora torquent per conubia nostra, +per inceptos himenaeos. [`Pellentesque aliquet quam enim`](/), eu volutpat urna +rutrum a. + +Nam vehicula nunc `:::js return target` mauris, a ultricies libero efficitur +sed. Sed molestie imperdiet consectetur. Vivamus a pharetra leo. Pellentesque +eget ornare tellus, ut gravida mi. Fusce vel lacinia lacus. + +### Listing + + #!js hl_lines="8" + var _extends = function(target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var key in source) { + target[key] = source[key]; + } + } + return target; + }; + +## Horizontal rules + +Aenean in finibus diam. Duis mollis est eget nibh volutpat, fermentum aliquet +dui mollis. Nam vulputate tincidunt fringilla. Nullam dignissim ultrices urna +non auctor. + +*** + +Integer vehicula feugiat magna, a mollis tellus. Nam mollis ex ante, quis +elementum eros tempor rutrum. Aenean efficitur lobortis lacinia. Nulla +consectetur feugiat sodales. + +## Data tables + +| Sollicitudo / Pellentesi | consectetur | adipiscing | elit | arcu | sed | +| ------------------------ | ----------- | ---------- | ------- | ---- | --- | +| Vivamus a pharetra | yes | yes | yes | yes | yes | +| Ornare viverra ex | yes | yes | yes | yes | yes | +| Mauris a ullamcorper | yes | yes | partial | yes | yes | +| Nullam urna elit | yes | yes | yes | yes | yes | +| Malesuada eget finibus | yes | yes | yes | yes | yes | +| Ullamcorper | yes | yes | yes | yes | yes | +| Vestibulum sodales | yes | - | yes | - | yes | +| Pulvinar nisl | yes | yes | yes | - | - | +| Pharetra aliquet est | yes | yes | yes | yes | yes | +| Sed suscipit | yes | yes | yes | yes | yes | +| Orci non pretium | yes | partial | - | - | - | + +Sed sagittis eleifend rutrum. Donec vitae suscipit est. Nullam tempus tellus +non sem sollicitudin, quis rutrum leo facilisis. Nulla tempor lobortis orci, +at elementum urna sodales vitae. In in vehicula nulla, quis ornare libero. + +| Left | Center | Right | +| :--------- | :------: | ------: | +| Lorem | *dolor* | `amet` | +| [ipsum](/) | **sit** | | + +Vestibulum vitae orci quis ante viverra ultricies ut eget turpis. Sed eu +lectus dapibus, eleifend nulla varius, lobortis turpis. In ac hendrerit nisl, +sit amet laoreet nibh. + + + + + + + + + + + + + + + + + + + + + + +
Tablewith colgroups (Pandoc)
Loremipsum dolor sit amet.
Sed sagittiseleifend rutrum. Donec vitae suscipit est.
\ No newline at end of file diff --git a/docs/8.0/ayanova/docs/api-console.md b/docs/8.0/ayanova/docs/api-console.md new file mode 100644 index 00000000..e7c9b25d --- /dev/null +++ b/docs/8.0/ayanova/docs/api-console.md @@ -0,0 +1,37 @@ +# API EXPLORER CONSOLE + +The AyaNova server uses [Swagger-ui](https://www.swagger.io) to provide an interactive live api explorer and documentation console for developers to learn about and experiment with the AyaNova REST API. + +You can access the api explorer console by navigating with your browser to this path on your AyaNova API server: +`/api-docs/` + +For example if your AyaNova server were located on port 7575 of the local computer you would connect to it via this url: +`http://localhost:7575/api-docs/` + +## Authentication + +Most of the API endpoints in AyaNova require authentication to use them. The API console supports the ability to set a authorization token so you can fully test all routes. + +To obtain a token expand the "Auth" route in the main console and enter a value for login and password and click on the "Try it out" button to obtain an API token. + +The "response body" section will contain the return value, something similar to this: + +``` hl_lines="5" +{ + "ok": 1, + "issued": 1518034370, + "expires": 1520626370, + "token": "xyGhbGciOiJIUzI1NiIsInR4cCI6IkpXVCJ9.utJpYXQ4OiIxNqE4MDM0MzcfIiwiZXhwjjoiMTUyMDYyNjM8MCIsImlocyI0IkF53U5vdmEiLCJpZCI6IjEifQ.z7QaHKt2VbcysunIvsfa-51X7owB1EYcyhpkdkfaqzy", + "id": 1 +} +``` + +The highlighted line above contains the token you require, copy the token value not including the quotation marks. This is your access token. + +Click on the "Authorize" button at the top of the API console and a popup dialog box will open. In the "Value" box the dialog enter the word Bearer followed by a space and then your api token, for example using the above you would paste in: + +`Bearer xyGhbGciOiJIUzI1NiIsInR4cCI6IkpXVCJ9.utJpYXQ4OiIxNqE4MDM0MzcfIiwiZXhwjjoiMTUyMDYyNjM8MCIsImlocyI0IkF53U5vdmEiLCJpZCI6IjEifQ.z7QaHKt2VbcysunIvsfa-51X7owB1EYcyhpkdkfaqzy` + +then click on the "Authorize" button inside the popup dialog box. + +You have now saved your credentials (until you close or reload this browser window) and can access any of the API endpoints in this test console you have permission to access with the credentials you supplied earlier. diff --git a/docs/8.0/ayanova/docs/api-error-codes.md b/docs/8.0/ayanova/docs/api-error-codes.md new file mode 100644 index 00000000..b41b0997 --- /dev/null +++ b/docs/8.0/ayanova/docs/api-error-codes.md @@ -0,0 +1,25 @@ +# API ERROR CODES + +The AyaNova API will return an [error response](api-response-format.md#error-responses) when an error condition arises. + +All API error codes are numbers between 2000 and 3000 and are intended to be consumed by software clients or for reference purposes for developers. + +API error codes are different from [server error codes](ops-error-codes.md) which are intended for AyaNova system operators and related only to the running of the server itself. + +Here are all the API level error codes that can be returned by the API server: + +| CODE | MEANING | +| ----- | ------------------------------ | +| 2000 | API closed - Server is running but access to the API has been closed to all users | +| 2001 | API closed all non OPS routes - Server is running but access to the API has been restricted to only server maintenance operations related functionality | +| 2002 | Internal error from the API server, details in [server log](common-log.md) file | +| 2003 | Authentication failed, bad login or password, user not found | +| 2004 | Not authorized - current user is not authorized for operation attempted on the resource (insufficient rights) | +| 2005 | Object was changed by another user since retrieval (concurrency token mismatch) | +| 2010 | Object not found - API could not find the object requested | +| 2020 | PUT Id mismatch - object Id does not match route Id | +| 2030 | Invalid operation - operation could not be completed, not valid, details in message property | +| 2200 | Validation error - general top level indicating object was not valid, specifics in "details" property | +| 2201 | Validation error - Field is required but is empty or null | +| 2202 | Validation error - Field length exceeded | +| 2203 | Validation error - invalid value | diff --git a/docs/8.0/ayanova/docs/api-intro.md b/docs/8.0/ayanova/docs/api-intro.md new file mode 100644 index 00000000..53402385 --- /dev/null +++ b/docs/8.0/ayanova/docs/api-intro.md @@ -0,0 +1,3 @@ +# DEVELOPERS API + +AyaNova REST API for developers diff --git a/docs/8.0/ayanova/docs/api-request-format.md b/docs/8.0/ayanova/docs/api-request-format.md new file mode 100644 index 00000000..3d60d0bd --- /dev/null +++ b/docs/8.0/ayanova/docs/api-request-format.md @@ -0,0 +1,8 @@ +# API request format + +AyaNova uses a RESTful API and supports the [JSON](https://www.json.org/) data interchange format exclusively. +No other data formats are supported, your code must supply and consume JSON formatted data. + +All developer interaction with the AyaNova API is via the REST server interface only. + +**TODO FILL THS OUT** \ No newline at end of file diff --git a/docs/8.0/ayanova/docs/api-response-format.md b/docs/8.0/ayanova/docs/api-response-format.md new file mode 100644 index 00000000..671fc584 --- /dev/null +++ b/docs/8.0/ayanova/docs/api-response-format.md @@ -0,0 +1,222 @@ +# API response format + +AyaNova uses a RESTful API and supports the [JSON](https://www.json.org/) data interchange format exclusively. +No other data formats are supported, your code must supply and consume JSON formatted data. + +All developer interaction with the AyaNova API is via the REST server interface only. + +## Successful responses + +### GET RESPONSE + +All successful GET responses have a standard format: + +```json +{ + "Result": { + "id": 150, + "name": "Handmade Rubber Pizza", + ...etc... + } +} +``` + +The results of the response are always contained in the `result` property and could be a single object, a collection or in some cases nothing at all. +HTTP Status Code is set in the header. + +### GET COLLECTION RESPONSE + +In the case of a collection most routes support paging, here is an example paged collection request and response: + +Request (note the `offset` and `limit` parameters): + +`http://localhost:3000/api/v8.0/Widget?Offset=2&Limit=3` + +Limit must be a value between 1 and 100. + +Response: + +```json +{ + "result": [ + ...collection... + ], + "paging": { + "count": 2000, + "offset": 2, + "limit": 3, + "first": "http://localhost:3000/api/v8.0/Widget?pageNo=1&pageSize=3", + "previous": "http://localhost:3000/api/v8.0/Widget?pageNo=1&pageSize=3", + "next": "http://localhost:3000/api/v8.0/Widget?pageNo=3&pageSize=3", + "last": "http://localhost:3000/api/v8.0/Widget?pageNo=667&pageSize=3" + } +} +``` + +`Previous` or `next` properties will contain "null" instead of an url on boundaries where there is no record to link to. + +### PUT RESPONSE + +A successful PUT response does not return any data but returns HTTP status code 204 (no content) in the header. + +**WARNING:** Be careful using PUT, you must provide **all** properties or any properties left out will be removed at the server. If you are updating a subset of properties use PATCH instead to save bandwidth. + +### PATCH RESPONSE + +Use PATCH to update specific properties only. + +A successful PATCH response does not return any data but returns HTTP status code 204 (no content) in the header. +Patches must conform to the [JSONPATCH](http://jsonpatch.com/) standard. + +### POST RESPONSE + +A successful POST response contains the object posted with it's Id value set and the HTTP status code of 201 (created). + +### DELETE RESPONSE + +A successful DELETE response does not return any data but returns HTTP status code 204 (no content) in the header. + +## Error responses + +### Fundamental errors + +Fundamental, basic errors return a header status code only and are generally self explanatory. For example if you attempt to use XML formatted data with the API you will receive an error response consisting only of the header 415 (unsuported media type). + +401 +In cases where authentication fails you will receive an empty body response with the header 401 (unauthorized) returned. +The details of what was wrong are contained in the header, here is an example of an invalid JWT authentication token: + +```json +{ + "content-length": "0", + "date": "Fri, 09 Mar 2018 16:46:07 GMT", + "server": "Kestrel", + "www-authenticate": "Bearer error=\"invalid_token\", error_description=\"The signature is invalid\"", + "content-type": null +} +``` + +### Error response object + +All error responses that return data have an `Error` object property at top level. The error object varies in the properties it contains depending on the error. + +Here is the most minimal error response that returns data: + +```json +{ + "Error": { + "Code": "2000", + "Message": "Developer readable error message" + } +} +``` + +An error object will always contain at minimum an [API error `Code`](api-error-codes.md) property for reference and a `message` property with descriptive text intended for developers. + +### Validation error response object + +Here is an example of a more detailed error response showing validation errors on a request: + +```json hl_lines="4 " +{ + "error": { + "code": "2200", + "details": [ + { + "message": "255 max", + "target": "Name", + "error": "LengthExceeded" + }, + { + "target": "EndDate", + "error": "RequiredPropertyEmpty" + }, + { + "target": "Roles", + "error": "InvalidValue" + } + ], + "message": "Object did not pass validation" + } +} +``` + +The above example shows multiple validation errors ([API error code](api-error-codes.md) 2200) in several properties when attempting to post an object. + +`details` outer property contains the collection of all validation errors. + +`target` property shows the location of the error. The value of `target` is either a property name corresponding to the property that failed business rule validation or blank if the validation rule applies to the entire object in general. + +`error` property contains the exact [validation error](api-validation-error-codes.md). + +`message` property optionally contains further information of use to the developer, in the example above you can see that the name property has more than the maximum limit of 255 characters. + +### Concurrency error response object + +AyaNova uses "optimistic concurrency" tracking. This means a concurrency token needs to accompany most change (PUT, PATCH) routes. + +Objects that require concurrency tokens to update are the objects that return a `ConcurrencyToken` property on a GET request. + +Every update to an object results in a new concurrency token for that object. + +In a concurrency error response ([API error code](api-error-codes.md) 2005) and header HTTP code 409 (Conflict) is returned if a user attempts to update a record that was changed by another user since it was retrieved (outdated concurrency token provided). + +Here is an example: + +```json +{ + "error": { + "code": "2005", + "message": "Object was changed by another user since retrieval (concurrency token mismatch)" + } +} +``` + +### Other errors response format + +Errors not related to validation or concurrency may contain one or more nested `innerError` properties. Each nested `innererror` object represents a higher level of detail than its parent. When evaluating errors, clients MUST traverse through all of the nested `innererrors` and choose the deepest one that they understand. + +Here is a sample error response with innererror set: + +```json hl_lines="6 " +{ + "error": { + "code": "1005", + "message": "Previous passwords may not be reused", + "target": "password", + "innererror": { + "code": "1006", + "innererror": { + "code": "1007", + "minLength": "6", + "maxLength": "64", + "characterTypes": ["lowerCase","upperCase","number","symbol"], + "minDistinctCharacterTypes": "2", + "innererror": { + "code": "1008" + } + } + } + } +} +``` + +Note that the contents of the `innererror` property may vary and contain distinct properties appropriate to the specific error condition. + +### Server internal errors + +Internal server errors are returned with an HTTP Status Code of 500 and an error object as follows: + +```json +{ + "error": { + "code": "2002", + "message": "See server log for details", + "target": "Server internal error" + } +} +``` + +For security reasons no details of an internal server exception are returned, you must examine the [server log](common-log.md) to see the details. +Generally this means the request triggered an unhandled exception which will be logged in detail to the log file. +Please report any internal server errors (preferrably with the log showing the exception details) to AyaNova support so we can look into it. diff --git a/docs/8.0/ayanova/docs/api-upload-routes.md b/docs/8.0/ayanova/docs/api-upload-routes.md new file mode 100644 index 00000000..99994910 --- /dev/null +++ b/docs/8.0/ayanova/docs/api-upload-routes.md @@ -0,0 +1,78 @@ +# API UPLOAD ROUTES + +AyaNova has several API routes for uploading files. + +These routes are all `POST` routes: + +- `/api/v{version}/Attachment` +- `/api/v{version}/ImportAyaNova7` +- `/api/v{version}/Restore` + +Upload routes are not testable from the API explorer. + +Upload routes expect a form to be uploaded with file content disposition (multipart/form-data). + +AyaNova will allow a maximum of 12gb per file upload for "Utility" routes such as restore and import routes. + +User file routes such as attachments may have a smaller limit, see the User documentation section for those features for limit details. + +Here is a sample minimal HTML form that works with AyaNova file routes: + +```html + + + + + + + + + + + + +
+ + + +
+ + +``` diff --git a/docs/8.0/ayanova/docs/api-validation-error-codes.md b/docs/8.0/ayanova/docs/api-validation-error-codes.md new file mode 100644 index 00000000..eea86890 --- /dev/null +++ b/docs/8.0/ayanova/docs/api-validation-error-codes.md @@ -0,0 +1,14 @@ +# API VALIDATION ERROR CODES + +All the validation error codes that can be [returned](api-response-format.md) by the API server. +In each case there may be more details in the `message` property where appropriate. + +| CODE | MEANING | +| ----- | ------------------------------ | +| RequiredPropertyEmpty | Required property is missing or empty | +| LengthExceeded | A text property has more characters than are allowed. The limit will be returned in the `message` property of the validation error | +| NotUnique | A text property is required to be unique but an existing identical value was found in the database | +| StartDateMustComeBeforeEndDate | When an object requires a start and end date the start date must be earlier than the end date | +| InvalidValue | Generic error indicating an input object's property is not set correctly | +| ReferentialIntegrity | Indicates modifying the object (usually a delete) will break the link to other records in the database. The other records need to be modified before continuing | +| InvalidOperation | Indicates the operation is invalid, details provided in the `message` | diff --git a/docs/8.0/ayanova/docs/common-log.md b/docs/8.0/ayanova/docs/common-log.md new file mode 100644 index 00000000..32b41347 --- /dev/null +++ b/docs/8.0/ayanova/docs/common-log.md @@ -0,0 +1,69 @@ +# LOGGING + +AyaNova keeps a log of important events for troubleshooting purposes. + +AyaNova logs to the file log-ayanova.txt. + +Every Wednesday it archives the current log file to a numbered archive log file, for example log-ayanova-1.txt, log-ayanova-2.txt etc. +Any log older than 4 weeks is deleted permanently; 4 total logs are kept, which means a total of one month of logs are kept at any given time. + +## INFORMATION SECURITY AND PRIVACY + +By design and policy no personally identifiable information is intentionally gathered into log files. + +User logins are logged in an anonymized way: + +* AyaNova anonymizes IP addresses by masking the final segment of the address +* Passwords are not logged +* Users are logged as their internal id number not their name + +Sometimes 3rd party tools may log to the log file and we may need to restrict them to conform to our privacy policy. If you find any personally identifiable information in a log file please advise us immediately at [support@ayanova.com](mailto:support@ayanova.com). + +## Log path + +By default AyaNova logs to a "logs" folder situated below the folder where AyaNova is started. +You can override this and set a custom log location by command line argument or by setting the "AYANOVA_LOG_PATH" environment variable. + +Example command line log path parameter + +`"dotnet run --AYANOVA_LOG_PATH=/home/gztw/Documents/temp/cmdlinelogs"` + +If both a command line parameter and an environment variable are set the command line parameter takes precedence. + +## Log level + +AyaNova supports 6 levels of logging, the default level is "Info" which is a medium level and will log general operations and any errors or warnings that may arise. + +**WARNING** +AyaNova server will run ***extremely slowly*** when setting a log level lower than Info. A very large amount of information is logged at Debug or lower levels and each item logged takes time away from the normal server operations. Unless directed to by technical support or attempting to diagnose a specific problem, you should avoid setting a log level lower than "Info". + +You can set the log level via environment variable or command line parameter "AYANOVA_LOG_LEVEL". + +For example from the command line + +`"dotnet run --AYANOVA_LOG_LEVEL=Info"` + +Below are listed the accepted values for log level from highest to lowest. A level logs everything at that level and above. +So, for example, "Trace" level will log the most and "Fatal" will log the least. + +* `Fatal` - Critical errors that prevent AyaNova from running or force it to shut down. This setting results in the least amount of logging. +* `Error` - Logs all of the above plus errors that AyaNova can recover from and continue to operate. +* `Warn` - Logs all of the above plus issues that AyaNova can work around but should be looked into and are warnings to system operators. +* `Info` - Default level. Logs all the above levels plus normal behavior in low level of detail. Basic general operations are logged like startup and shutdown, configuration changes etc. +* `Debug` - Logs all the above plus every request to the server in detail and information about internal operations. +* `Trace` - Logs all the above plus highest level detail of internal program code operations. Useful primarily to AyaNova technical support to troubleshoot a specific issue, but too detailed for normal purposes. + +## Troubleshooting logging + +If you are having issues with logging you can enable a logger diagnostic log with a command line parameter or environment variable. +Enabling this setting will cause a log file named "log-ayanova-logger.txt" to be written to the folder AyaNova is started in. + +Command line parameter + +`-- AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG=true` + +or set the environment variable + +`AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG = true` + +Warning: this diagnostic log should be disabled as soon as it's not required. Unlike the normal log, this log file is not automatically trimmed so it will grow in size forever. diff --git a/docs/8.0/ayanova/docs/img/ayanovaicon48.png b/docs/8.0/ayanova/docs/img/ayanovaicon48.png new file mode 100644 index 00000000..ac8afe1e Binary files /dev/null and b/docs/8.0/ayanova/docs/img/ayanovaicon48.png differ diff --git a/docs/8.0/ayanova/docs/img/ayanovaicon60x60.png b/docs/8.0/ayanova/docs/img/ayanovaicon60x60.png new file mode 100644 index 00000000..a3a603a8 Binary files /dev/null and b/docs/8.0/ayanova/docs/img/ayanovaicon60x60.png differ diff --git a/docs/8.0/ayanova/docs/img/dbdump.png b/docs/8.0/ayanova/docs/img/dbdump.png new file mode 100644 index 00000000..7a14938b Binary files /dev/null and b/docs/8.0/ayanova/docs/img/dbdump.png differ diff --git a/docs/8.0/ayanova/docs/img/favicon.ico b/docs/8.0/ayanova/docs/img/favicon.ico new file mode 100644 index 00000000..2e95d774 Binary files /dev/null and b/docs/8.0/ayanova/docs/img/favicon.ico differ diff --git a/docs/8.0/ayanova/docs/img/v8ServerMetricsDashboard.png b/docs/8.0/ayanova/docs/img/v8ServerMetricsDashboard.png new file mode 100644 index 00000000..d65527a7 Binary files /dev/null and b/docs/8.0/ayanova/docs/img/v8ServerMetricsDashboard.png differ diff --git a/docs/8.0/ayanova/docs/img/v8ServerMetricsSnapshotText.png b/docs/8.0/ayanova/docs/img/v8ServerMetricsSnapshotText.png new file mode 100644 index 00000000..15d3c4b1 Binary files /dev/null and b/docs/8.0/ayanova/docs/img/v8ServerMetricsSnapshotText.png differ diff --git a/docs/8.0/ayanova/docs/index.md b/docs/8.0/ayanova/docs/index.md new file mode 100644 index 00000000..d606bec1 --- /dev/null +++ b/docs/8.0/ayanova/docs/index.md @@ -0,0 +1,23 @@ +# WELCOME TO AYANOVA ![AyaNovaIcon](img/ayanovaicon60x60.png) + +## About this documentation + +This manual has the following sections: + +- **User** + + User manual and guide to AyaNova features + +- **Operations** + + Technical guide for installation and ongoing maintenance operations of AyaNova + +- **Developer** + + Guide for software developers to use the AyaNova REST interface + +## Beyond this manual + +If you have a question that is not answered in this manual contact AyaNova support directly: [support@ayanova.com](mailto:support@ayanova.com) + +Or check out our support forum [https://forum.ayanova.com/](https://forum.ayanova.com/) diff --git a/docs/8.0/ayanova/docs/intro.md b/docs/8.0/ayanova/docs/intro.md new file mode 100644 index 00000000..a3a1d149 --- /dev/null +++ b/docs/8.0/ayanova/docs/intro.md @@ -0,0 +1,9 @@ +# INTRODUCTION + +How to use this help manual + +## Searching + +- one +- two +- three diff --git a/docs/8.0/ayanova/docs/ops-config-db.md b/docs/8.0/ayanova/docs/ops-config-db.md new file mode 100644 index 00000000..12667b4c --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-config-db.md @@ -0,0 +1,33 @@ +# DATABASE + +AyaNova uses [PostgreSQL](https://www.postgresql.org/) as it's database server in all configurations, no other database is supported. + +## Default connection string + +If no connection string is specified AyaNova will use a default value: "Server=localhost;". + +## Setting the connection string + +AyaNova expects the connection string to be provided by an environment variable or command line parameter named: + +`AYANOVA_DB_CONNECTION` + +Example command line parameter: + +`dotnet run --AYANOVA_DB_CONNECTION="Server=localhost;Database=MyAyaNovaDB;"` + +Example environment variable: + +Windows: + +`set "AYANOVA_DB_CONNECTION=Server=localhost;Database=MyAyaNovaDB;"` + +Linux: + +`export AYANOVA_DB_CONNECTION="Server=localhost;Database=MyAyaNovaDB;"` + +If both a command line parameter and an environment variable are set the command line parameter takes precedence. + +## Default database + +If no default database is specified AyaNova will use the default value: "AyaNova". diff --git a/docs/8.0/ayanova/docs/ops-config-default-language.md b/docs/8.0/ayanova/docs/ops-config-default-language.md new file mode 100644 index 00000000..4ea34fb3 --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-config-default-language.md @@ -0,0 +1,56 @@ +# DEFAULT LANGUAGE / LOCALE SETTING + +This setting controls the default language for text displayed to users in the AyaNova user interface. + +Users can choose to override this setting in their user account by choosing an another language. + +It will also be used for some messages that originate at the server and are not associated with a particular user where applicable. + +## Default + +If no language is specified or AyaNova can't find the language specified in the database then AyaNova defaults to English locale "en". + +## Built in language values + +In addition to user defined or customized languages, AyaNova comes with 4 "stock" languages built in and accepts a range of values for selecting the stock language. +You can use the ISO two letter country code or the English name of the language or that languages own name for the language. + +Valid settings: + +| LANGUAGE | VALID SETTINGS | +| ----- | ------------------------------ | +| English | "en", "English" | +| French | "fr", "French", "Français" | +| German | "de", "German", "Deutsch" | +| Spanish | "es", "Spanish", "Español" | + +## Custom language values + +AyaNova allows for customized languages and this setting should be the exact name of a custom locale that exists within AyaNova if not using a built in language. + +## Setting + +AyaNova expects the language setting to be provided by an environment variable or command line parameter named + +`AYANOVA_DEFAULT_LANGUAGE` + +The value specified should be a string containing one of the stock valid settings in the table above or the name of a custom locale, for example: +`French` +or +`AcmeWidgetsCustomLocale` + +Example command line parameter + +`dotnet run --AYANOVA_DEFAULT_LANGUAGE="ES"` + +Example environment variable + +Windows + +`set "AYANOVA_DEFAULT_LANGUAGE=DE"` + +Linux / MAC + +`export AYANOVA_DEFAULT_LANGUAGE="MyCustomLocale"` + +If both a command line parameter and an environment variable are set the command line parameter takes precedence. diff --git a/docs/8.0/ayanova/docs/ops-config-environment-variables.md b/docs/8.0/ayanova/docs/ops-config-environment-variables.md new file mode 100644 index 00000000..f56a50ca --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-config-environment-variables.md @@ -0,0 +1,41 @@ +# ENVIRONMENT VARIABLES / COMMAND LINE ARGUMENTS LIST + +Most of the AyaNova configuration is stored inside the database, however anything related to starting the server can not be stored in the database and so environment variables or command line parameters are used to control server start up settings. + +These values can all be specified as an environment variable or as a command line parameter. In cases where both are specified, the command line parameter takes precedence. + +## DATABASE + +- [AYANOVA_DB_CONNECTION](ops-config-db.md) + +## FILE STORAGE LOCATIONS + +- [AYANOVA_FOLDER_BACKUP_FILES](ops-config-folder-backup-files.md) +- [AYANOVA_FOLDER_USER_FILES](ops-config-folder-user-files.md) + +## LOGGING + +- [AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG](common-log.md#troubleshooting-logging) +- [AYANOVA_LOG_LEVEL](common-log.md#log-level) +- [AYANOVA_LOG_PATH](common-log.md#log-path) + +## LANGUAGE / LOCALE + +- [AYANOVA_DEFAULT_LANGUAGE](ops-config-default-language.md) + +## API + +- [AYANOVA_USE_URLS](ops-config-use-urls.md) +- [AYANOVA_FOLDER_USER_FILES](ops-config-folder-user-files.md) +- [AYANOVA_FOLDER_BACKUP_FILES](ops-config-folder-backup-files.md) + +## METRICS + +- [AYANOVA_METRICS_USE_INFLUXDB](ops-metrics.md) +- [AYANOVA_METRICS_INFLUXDB_BASEURL](ops-metrics.md) +- [AYANOVA_METRICS_INFLUXDB_DBNAME](ops-metrics.md) +- [AYANOVA_METRICS_INFLUXDB_CONSISTENCY](ops-metrics.md) +- [AYANOVA_METRICS_INFLUXDB_USERNAME](ops-metrics.md) +- [AYANOVA_METRICS_INFLUXDB_PASSWORD](ops-metrics.md) +- [AYANOVA_METRICS_INFLUXDB_RETENSION_POLICY](ops-metrics.md) +- [AYANOVA_METRICS_INFLUXDB_CREATE_DATABASE_IF_NOT_EXISTS](ops-metrics.md) diff --git a/docs/8.0/ayanova/docs/ops-config-folder-backup-files.md b/docs/8.0/ayanova/docs/ops-config-folder-backup-files.md new file mode 100644 index 00000000..7298877d --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-config-folder-backup-files.md @@ -0,0 +1,35 @@ +# BACKUP FILES FOLDER SETTING + +This setting controls where AyaNova stores backup and restore files used by the backup and restore features built into AyaNova. +In addition this folder is used when importing from an AyaNova 7 export file. + +Warning: this folder MUST NOT be the same location set for [AYANOVA_FOLDER_USER_FILES](ops-config-folder-user-files.md) or AyaNova will not start. + +## Default + +If no override is specified AyaNova will store backup files in a `backupfiles` folder in the AyaNova root folder where AyaNova is started from. + +## Overriding + +AyaNova expects the backup files folder path to be provided by an environment variable or command line parameter named + +`AYANOVA_FOLDER_BACKUP_FILES` + +The value specified should be a string containing a fully qualified file path for the platform, for example: +`c:\data\ayanova\backupfiles` + +Example command line parameter + +`dotnet run --AYANOVA_FOLDER_BACKUP_FILES="/var/lib/ayanova/backupfiles"` + +Example environment variable + +Windows + +`set "AYANOVA_FOLDER_BACKUP_FILES=c:\data\ayanova\backupfiles"` + +Linux / MAC + +`export AYANOVA_FOLDER_BACKUP_FILES="/var/lib/ayanova/backupfiles"` + +If both a command line parameter and an environment variable are set the command line parameter takes precedence. diff --git a/docs/8.0/ayanova/docs/ops-config-folder-user-files.md b/docs/8.0/ayanova/docs/ops-config-folder-user-files.md new file mode 100644 index 00000000..33402520 --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-config-folder-user-files.md @@ -0,0 +1,35 @@ +# USER FILES FOLDER SETTING + +This setting controls where AyaNova stores user uploaded files used by features that allow file attachment or uploads. +AyaNova stores these files with random names in the folder specified. + +Warning: this folder MUST NOT be the same location set for [AYANOVA_FOLDER_BACKUP_FILES](ops-config-folder-backup-files.md) or AyaNova will not start. + +## Default + +If no override is specified AyaNova will store user files in a `userfiles` folder in the AyaNova root folder where AyaNova is started from. + +## Overriding + +AyaNova expects the user files folder path to be provided by an environment variable or command line parameter named + +`AYANOVA_FOLDER_USER_FILES` + +The value specified should be a string containing a fully qualified file path for the platform, for example: +`c:\data\ayanova\userfiles` + +Example command line parameter + +`dotnet run --AYANOVA_FOLDER_USER_FILES="/var/lib/ayanova/userfiles"` + +Example environment variable + +Windows + +`set "AYANOVA_FOLDER_USER_FILES=c:\data\ayanova\userfiles"` + +Linux / MAC + +`export AYANOVA_FOLDER_USER_FILES="/var/lib/ayanova/userfiles"` + +If both a command line parameter and an environment variable are set the command line parameter takes precedence. diff --git a/docs/8.0/ayanova/docs/ops-config-use-urls.md b/docs/8.0/ayanova/docs/ops-config-use-urls.md new file mode 100644 index 00000000..87829e4a --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-config-use-urls.md @@ -0,0 +1,36 @@ +# PORT / URL SETTING + +You can control the port and URL that the AyaNova server will listen on via environment variable or command line parameter. + +## Default + +If no override is specified AyaNova will use the following default value: + +`"http://*:7575"` + +This means AyaNova will listen on port 7575 + +## Overriding + +AyaNova expects the PORT and URL to be provided by an environment variable or command line parameter named + +`AYANOVA_USE_URLS` + +The value specified should be a string of one or more semicolon separated values, for example: +`http://*:5000;http://localhost:5001;https://hostname:5002` + +Example command line parameter + +`dotnet run --AYANOVA_USE_URLS="http://*:5000"` + +Example environment variable + +Windows + +`set "AYANOVA_USE_URLS=http://*:5000"` + +Linux / MAC + +`export AYANOVA_USE_URLS="http://*:5000"` + +If both a command line parameter and an environment variable are set the command line parameter takes precedence. diff --git a/docs/8.0/ayanova/docs/ops-error-codes.md b/docs/8.0/ayanova/docs/ops-error-codes.md new file mode 100644 index 00000000..89992c60 --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-error-codes.md @@ -0,0 +1,28 @@ +# SERVER ERROR CODES + +AyaNova will provide a server error code when an error arises. +All AyaNova server error codes start with the letter E followed by a number in the range 1000-1999. + +Server error codes are different from [API error codes](api-error-codes.md) which are intended for software and developers using the AyaNova developers API. + +The purpose of these server error codes is to make it easier to look them up in this manual and easily communicate errors to technical support if necessary. + +In most cases where an error occurs there will be more detailed information about the error in the [log file](common-log.md). + +Here are all the error codes that can be returned by the AyaNova server: + +| CODE | MEANING | +| ----- | ------------------------------ | +| E1000 | Could not connect to the database specified in the [connection string](ops-config-db.md). | +| E1010 | Could not find wwwRoot folder. AyaNova must be started from the folder immediately above wwwRoot. Generally the start folder should be the same folder as AyaNova.dll file. | +| E1012 | Missing resource folder. AyaNova was started from the wrong location or the resource folder was not installed properly. This is required to intialize a new AyaNova database | +| E1013 | Missing language resource file was deleted, renamed or not installed correctly. Resource language files are required to load into a new AyaNova database to display text in several languages for the user interface | +| E1015 | Missing language. One or more of the stock languages were not found in the database or a custom language specified in the config setting [AYANOVA_DEFAULT_LANGUAGE](ops-config-default-language.md) is missing from the database. Log will have details. | +| E1020 | Licensing related error. The message will contain the explanation | +| E1030 | AyaNova database failed an integrity check. Contact support immediately. | +| E1040 | File location [environment variables](ops-config-environment-variables.md) for backup files and user files were found to be the same location and must not be | +| E1050 | XXXXXXXX | +| E1060 | XXXXXXXX | +| E1070 | XXXXXXXX | +| E1080 | XXXXXXXX | +| E1090 | AyaNova failed to start due to an unexpected error during boot. | \ No newline at end of file diff --git a/docs/8.0/ayanova/docs/ops-import-v7.md b/docs/8.0/ayanova/docs/ops-import-v7.md new file mode 100644 index 00000000..6a465dc2 --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-import-v7.md @@ -0,0 +1,64 @@ +# IMPORTING OLDER AYANOVA DATA + +AyaNova 8+ can import data from AyaNova 7.5. For versions of AyaNova older than 7.5 you must first upgrade them to 7.5 before continuing. + +## OVERVIEW + +We have created a DBDump plugin for AyaNova 7.5 that will export the entire database into a universal format (JSON text files in a zip archive) that can be imported by AyaNova 8+. + +Importing AyaNova 7.5 data is a two step process. + +- Export data from AyaNova 7.5 using the DBDump plugin +- Import the export data into AyaNova 8 or above + +## WARNINGS + +### Do not rename the export file + +AyaNova 8+ expects the export file to have a specific name format. For example: `ayanova.data.dump.2018-10-31--11-18-16.zip`. + +If you rename the file AyaNova 8 may not recognize it as a valid DBDump file and will not offer it as an option for import. + +The DBDump filename contains the date and time the export was started `ayanova.data.dump.YEAR-MONTH-DAY--HOUR-MINUTE-SECOND.zip` + +### Repeated import not supported + +Importing 7.5 data more than once to the same AyaNova 8+ database is not supported and could result in damage to data integrity. + +Note that it's possible to import into a database more than once for test and evaluation purposes as long as you erase it before each import, however data should not be imported more than once into the same database without erasing it first to ensure data integrity. + +In other words you cannot continue to work in both AyaNova 7.5 and AyaNova 8 at the same time and expect to export and import data repeatedly to keep them in "sync". + +### Multi-user networked AyaNova 7.5 + +If other users are working in AyaNova when the DBDump plugin is run the resulting export file will not be valid as there could be records changed or missing that will be required to import. Be certain no other users are working in AyaNova before you run the DBDump. + +Generator: Be sure to stop the AyaNova Generator *before* starting the DBDump plugin. Failing to do so could result in a corrupt export file as there could be records changed or missing that are required for import. + +If you have a networked installation of AyaNova 7.5, when you are ready to transition to the newer version of AyaNova you will need to ensure that no other users are still working in AyaNova 7.5 after you do the final DBDump. + +We recommend stopping the old database server immediately after the final DBDump in case there is a chance that there are still users inside or outside of your network that may access AyaNova 7.5. + +### Before uninstalling AyaNova 7.5 + +Examine your imported data in AyaNova 8+; carefully ensure that the data you expect to see has been imported properly. You may still need to make another export in case of any issues that arise so it's not a good idea to immediately uninstall AyaNova 7.5 until you are sure the newer version of AyaNova has all your data in it and is ready for business. + +We recommend keeping your AyaNova 7.5 installation for at least a month in case an issue comes up. + +### Keep your last AyaNova 7.5 backup + +We recommend you keep a backup copy of your AyaNova 7.5 database in a safe location for at least a year after transitioning to AyaNova 8+ in case any issues arise. + +## EXPORT FROM AYANOVA 7.5 + +1. Avoid future problems: If you haven't already, go back and read the section titled "WARNINGS" above carefully. +2. [Download the DBDump plugin](https://ayanova.com/TODO) installer and run it on a computer with AyaNova 7.5 already installed and configured. +3. Pick an export location: Ensure you have enough free space in the location you are going to save the DBDump file to; you may require as much as double the space that your current AyaNova database is using. +4. Multi-user only: If exporting from a networked multi-user AyaNova 7.5 installation, now is the time to ensure all users are out and networked Generator service is stopped. **Do not proceed until this is verfied**. +5. Login to AyaNova 7.5 as the Manager account and select and run the "DBDump" plugin: ![DBDump](img/dbdump.png) +6. Select the location to save the dump file to from step 3 above. +7. A form will popup and show the progress of the DBDump operation and the name and location of the dump file. When completed a single .ZIP file will be created containing all the data that can be imported from AyaNova 7.5 in a format ready for import into AyaNova 8+. If this is your final export before moving to AyaNova 8+ you should keep a permanent copy of this file as a precaution. + +## IMPORT TO AYANOVA 8+ + +TODO once we have UI \ No newline at end of file diff --git a/docs/8.0/ayanova/docs/ops-metrics.md b/docs/8.0/ayanova/docs/ops-metrics.md new file mode 100644 index 00000000..a86b0c41 --- /dev/null +++ b/docs/8.0/ayanova/docs/ops-metrics.md @@ -0,0 +1,63 @@ +# METRICS + +AyaNova 8+ automatically tracks server metrics for ongoing server maintenance, monitoring and troubleshooting. + +## OVERVIEW + +Metrics are statistical and other information gathered automatically during server operation that can be used to assess the health of an AyaNova server. +This information is typically useful to the Operations staff who are responsible for maintaining the AyaNova server in good working condition. + +When the AyaNova server is booted it starts gathering snapshots of statistical data during regular intervals that can be viewed to observe the current state of the server and some historical data from the point it was last rebooted. + +Some examples of the metrics gathered include: + +- Performance per API endpoint routes +- Error rates per HTTP error code and API endpoint route +- Transactions per endpoint +- Database records per table of significance +- Count and size of user files (attachments) stored at the server +- Count and size of operations files (backups, import/export etc) stored at the server +- Job operations data about background process jobs (notifications, backups, maintenance etc) running, succeeded and failed +- Memory usage of the server +- And more + +## ROLES AND RIGHTS + +Metrics are available to users with the `OPS - full` or `OPS - limited` roles. + +## INFORMATION SECURITY AND PRIVACY + +By design and policy no personally identifiable information is gathered for metrics. The data about API routes consists of consolidated information gathered over multiple users and does not track per IP address. + +## VIEWING SNAPSHOT METRICS + +View a current metrics snapshot directly on the server via the [API Explorer](api-console.md) tool: + +![API Explorer](img/v8ServerMetricsSnapshotText.png) + +TODO: VIEW METRICS IN AYANOVA CLIENT UI + +## TAKING IT TO THE NEXT LEVEL - STORING METRICS AND VIEWING GRAPHICALLY + +AyaNova has built in support to send metrics snapshots automatically to the open source time series database [InfluxDB](https://www.influxdata.com/) and can be viewed with the open source analytics and monitoring tool [Grafana](https://grafana.com/) + +Example of a testing run of AyaNova during development visualized with Grafana and InfluxDB hosted in a Docker container: + + ![Grafana in Docker](img/v8ServerMetricsDashboard.png) + +### Configuration settings for InfluxDB + +Use of InfluxDB for metrics is controlled with [environment variables](ops-config-environment-variables.md) read during startup of the AyaNova server: + +- `AYANOVA_METRICS_USE_INFLUXDB` true / false value, default is `false` set to `true` to turn on metrics reporting to InfluxDB +- `AYANOVA_METRICS_INFLUXDB_BASEURL` string value uri to your InfluxDB server default value is `http://127.0.0.1:8086` +- `AYANOVA_METRICS_INFLUXDB_DBNAME` string value name of database to use with InfluxDB server default value is `AyaNova` +- `AYANOVA_METRICS_INFLUXDB_CONSISTENCY` string value name of InfluxDB consistency policy to use with InfluxDB server default value is empty and not set +- `AYANOVA_METRICS_INFLUXDB_USERNAME` string value user name of account to connect to database default value is `root` +- `AYANOVA_METRICS_INFLUXDB_PASSWORD` string value password of account to connect to database default value is `root` +- `AYANOVA_METRICS_INFLUXDB_RETENSION_POLICY` string value name of InfluxDB retention policy to use with InfluxDB server default value is empty and not set +- `AYANOVA_METRICS_INFLUXDB_CREATE_DATABASE_IF_NOT_EXISTS` true / false value, default is `true` set to `true` to automatically create database in InfluxDB if it doesn't exist + +### Setting up a Grafana dashboard + +TODO: dashboard setup and mention of docker \ No newline at end of file diff --git a/docs/8.0/ayanova/mkdocs.yml b/docs/8.0/ayanova/mkdocs.yml new file mode 100644 index 00000000..4a56367c --- /dev/null +++ b/docs/8.0/ayanova/mkdocs.yml @@ -0,0 +1,40 @@ +theme: + name: 'material' + favicon: 'img/favicon.ico' + logo: 'img/ayanovaicon60x60.png' + palette: + primary: 'indigo' + accent: 'indigo' +site_name: AyaNova manual +site_dir: '../../../server/AyaNova/wwwroot/docs' +# Extensions +markdown_extensions: + - admonition + - codehilite: + guess_lang: false + - toc: + permalink: true +pages: +- Home: 'index.md' +- User guide: + - 'Introduction': 'intro.md' +- Operations guide: + - 'Installation': '_placeholder.md' + - 'Logging': 'common-log.md' + - 'Language / locale': 'ops-config-default-language.md' + - 'Backup files folder': 'ops-config-folder-backup-files.md' + - 'User files folder': 'ops-config-folder-user-files.md' + - 'Database configuration': 'ops-config-db.md' + - 'PORT and URL configuration': 'ops-config-use-urls.md' + - 'Environment variable reference': 'ops-config-environment-variables.md' + - 'Server error codes': 'ops-error-codes.md' + - 'Importing from older AyaNova': 'ops-import-v7.md' + - 'Metrics': 'ops-metrics.md' +- Developer guide: + - 'Introduction': 'api-intro.md' + - 'API developers console': 'api-console.md' + - 'API request format': 'api-request-format.md' + - 'API response format': 'api-response-format.md' + - 'API error codes': 'api-error-codes.md' + - 'API validation error codes': 'api-validation-error-codes.md' + - 'API upload routes': 'api-upload-routes.md' diff --git a/linecounts/linecount.txt b/linecounts/linecount.txt new file mode 100644 index 00000000..7fc082c8 --- /dev/null +++ b/linecounts/linecount.txt @@ -0,0 +1,497 @@ +=============================================================================== +EXTENSION NAME : linecount +EXTENSION VERSION : 0.1.7 +------------------------------------------------------------------------------- +count time : 2018-06-05 15:09:19 +count workspace : c:\data\code\raven +total files : 150 +total code lines : 8257 +total comment lines : 3280 +total blank lines : 2834 + +dist\docker\linux-x64\ayanovadocker\dockerfile, code is 4, comment is 0, blank is 0. +dist\docker\linux-x64\docker-compose.yml, code is 47, comment is 2, blank is 5. +dist\docker\linux-x64\docker-compose.yml.original.b4.metrics, code is 37, comment is 1, blank is 4. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\docker-compose.yml, code is 15, comment is 0, blank is 3. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\letsencrypt-site\index.html, code is 5, comment is 0, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\nginx.conf, code is 11, comment is 0, blank is 2. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\dh-param\dhparam-2048.pem, code is 8, comment is 1, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\docker-compose.yml, code is 19, comment is 0, blank is 3. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\production-site\index.html, code is 13, comment is 0, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\production.conf, code is 120, comment is 6, blank is 13. +dist\docker\linux-x64\restartnginx.sh, code is 3, comment is 3, blank is 0. +makedocs.bat, code is 2, comment is 0, blank is 0. +makedocs.sh, code is 2, comment is 1, blank is 0. +server\AyaNova\appsettings.Development.json, code is 10, comment is 0, blank is 0. +server\AyaNova\appsettings.json, code is 15, comment is 0, blank is 2. +server\AyaNova\AyaNova.csproj, code is 12, comment is 0, blank is 0. +server\AyaNova\biz\AttachableAttribute.cs, code is 8, comment is 7, blank is 1. +server\AyaNova\biz\AuthorizationRoles.cs, code is 27, comment is 26, blank is 6. +server\AyaNova\biz\AyaObjectOwnerId.cs, code is 29, comment is 7, blank is 15. +server\AyaNova\biz\AyaType.cs, code is 23, comment is 126, blank is 7. +server\AyaNova\biz\AyaTypeId.cs, code is 69, comment is 16, blank is 19. +server\AyaNova\biz\BizObject.cs, code is 54, comment is 9, blank is 22. +server\AyaNova\biz\BizObjectFactory.cs, code is 38, comment is 4, blank is 13. +server\AyaNova\biz\BizRoles.cs, code is 83, comment is 44, blank is 24. +server\AyaNova\biz\BizRoleSet.cs, code is 11, comment is 5, blank is 1. +server\AyaNova\biz\IBizObject.cs, code is 16, comment is 28, blank is 17. +server\AyaNova\biz\IImportAyaNova7Object.cs, code is 12, comment is 10, blank is 6. +server\AyaNova\biz\IJobObject.cs, code is 8, comment is 9, blank is 5. +server\AyaNova\biz\ImportAyaNova7Biz.cs, code is 90, comment is 41, blank is 32. +server\AyaNova\biz\JobOperationsBiz.cs, code is 72, comment is 8, blank is 31. +server\AyaNova\biz\JobsBiz.cs, code is 204, comment is 106, blank is 74. +server\AyaNova\biz\JobStatus.cs, code is 12, comment is 4, blank is 2. +server\AyaNova\biz\JobType.cs, code is 13, comment is 5, blank is 4. +server\AyaNova\biz\TagBiz.cs, code is 161, comment is 55, blank is 62. +server\AyaNova\biz\TaggableAttribute.cs, code is 8, comment is 7, blank is 1. +server\AyaNova\biz\TagMapBiz.cs, code is 88, comment is 51, blank is 44. +server\AyaNova\biz\TrialBiz.cs, code is 51, comment is 26, blank is 22. +server\AyaNova\biz\ValidationError.cs, code is 11, comment is 2, blank is 1. +server\AyaNova\biz\ValidationErrorType.cs, code is 14, comment is 1, blank is 5. +server\AyaNova\biz\WidgetBiz.cs, code is 186, comment is 63, blank is 67. +server\AyaNova\ControllerHelpers\ApiCreatedResponse.cs, code is 13, comment is 2, blank is 6. +server\AyaNova\ControllerHelpers\ApiCustomExceptionFilter.cs, code is 55, comment is 29, blank is 17. +server\AyaNova\ControllerHelpers\ApiDetailError.cs, code is 18, comment is 5, blank is 8. +server\AyaNova\ControllerHelpers\ApiError.cs, code is 23, comment is 5, blank is 10. +server\AyaNova\ControllerHelpers\ApiErrorCode.cs, code is 26, comment is 5, blank is 7. +server\AyaNova\ControllerHelpers\ApiErrorCodeStockMessage.cs, code is 45, comment is 8, blank is 6. +server\AyaNova\ControllerHelpers\ApiErrorResponse.cs, code is 62, comment is 11, blank is 33. +server\AyaNova\ControllerHelpers\ApiNotAuthorizedResponse.cs, code is 22, comment is 4, blank is 9. +server\AyaNova\ControllerHelpers\ApiOkResponse.cs, code is 13, comment is 2, blank is 6. +server\AyaNova\ControllerHelpers\ApiOkWithPagingResponse.cs, code is 16, comment is 7, blank is 9. +server\AyaNova\ControllerHelpers\ApiPagedResponse.cs, code is 17, comment is 2, blank is 7. +server\AyaNova\ControllerHelpers\ApiServerState.cs, code is 117, comment is 39, blank is 36. +server\AyaNova\ControllerHelpers\ApiUploadProcessor.cs, code is 126, comment is 45, blank is 40. +server\AyaNova\ControllerHelpers\Authorized.cs, code is 57, comment is 35, blank is 30. +server\AyaNova\ControllerHelpers\DisableFormValueModelBindingAttribute.cs, code is 31, comment is 14, blank is 5. +server\AyaNova\ControllerHelpers\MultipartRequestHelper.cs, code is 42, comment is 28, blank is 7. +server\AyaNova\ControllerHelpers\PaginationLinkBuilder.cs, code is 65, comment is 7, blank is 18. +server\AyaNova\ControllerHelpers\PagingOptions.cs, code is 19, comment is 0, blank is 4. +server\AyaNova\ControllerHelpers\UserIdFromContext.cs, code is 17, comment is 1, blank is 4. +server\AyaNova\ControllerHelpers\UserNameFromContext.cs, code is 17, comment is 1, blank is 3. +server\AyaNova\ControllerHelpers\UserRolesFromContext.cs, code is 15, comment is 1, blank is 3. +server\AyaNova\Controllers\ApiRootController.cs, code is 28, comment is 11, blank is 12. +server\AyaNova\Controllers\AttachmentController.cs, code is 245, comment is 136, blank is 75. +server\AyaNova\Controllers\AuthController.cs, code is 99, comment is 46, blank is 23. +server\AyaNova\Controllers\AyaTypeController.cs, code is 48, comment is 15, blank is 20. +server\AyaNova\Controllers\BackupController.cs, code is 38, comment is 122, blank is 34. +server\AyaNova\Controllers\ImportAyaNova7Controller.cs, code is 166, comment is 73, blank is 48. +server\AyaNova\Controllers\JobOperationsController.cs, code is 68, comment is 35, blank is 33. +server\AyaNova\Controllers\LicenseController.cs, code is 116, comment is 46, blank is 37. +server\AyaNova\Controllers\LogFilesController.cs, code is 78, comment is 62, blank is 37. +server\AyaNova\Controllers\MetricsController.cs, code is 79, comment is 37, blank is 21. +server\AyaNova\Controllers\ServerStateController.cs, code is 60, comment is 40, blank is 21. +server\AyaNova\Controllers\TagController.cs, code is 216, comment is 82, blank is 69. +server\AyaNova\Controllers\TagMapController.cs, code is 147, comment is 51, blank is 62. +server\AyaNova\Controllers\TrialController.cs, code is 69, comment is 41, blank is 18. +server\AyaNova\Controllers\WidgetController.cs, code is 279, comment is 116, blank is 89. +server\AyaNova\generator\BackgroundService.cs, code is 45, comment is 13, blank is 14. +server\AyaNova\generator\CoreJobMetricsReport.cs, code is 25, comment is 15, blank is 17. +server\AyaNova\generator\CoreJobMetricsSnapshot.cs, code is 72, comment is 26, blank is 38. +server\AyaNova\generator\CoreJobSweeper.cs, code is 76, comment is 30, blank is 26. +server\AyaNova\generator\Generate.cs, code is 67, comment is 27, blank is 30. +server\AyaNova\logs\log-ayanova.txt, code is 186, comment is 0, blank is 0. +server\AyaNova\models\AyContext.cs, code is 55, comment is 16, blank is 24. +server\AyaNova\models\dto\ImportV7MapItem.cs, code is 16, comment is 3, blank is 1. +server\AyaNova\models\dto\JobOperationsFetchInfo.cs, code is 13, comment is 23, blank is 4. +server\AyaNova\models\dto\JobOperationsLogInfoItem.cs, code is 10, comment is 11, blank is 5. +server\AyaNova\models\dto\NameItem.cs, code is 9, comment is 3, blank is 0. +server\AyaNova\models\dto\NameValueActiveItem.cs, code is 11, comment is 0, blank is 1. +server\AyaNova\models\dto\NameValueItem.cs, code is 10, comment is 0, blank is 0. +server\AyaNova\models\dto\TagMapInfo.cs, code is 12, comment is 0, blank is 2. +server\AyaNova\models\dto\TypeAndIdInfo.cs, code is 11, comment is 0, blank is 2. +server\AyaNova\models\dto\UploadedFileInfo.cs, code is 11, comment is 3, blank is 0. +server\AyaNova\models\FileAttachment.cs, code is 27, comment is 5, blank is 2. +server\AyaNova\models\License.cs, code is 16, comment is 0, blank is 4. +server\AyaNova\models\OpsJob.cs, code is 44, comment is 9, blank is 9. +server\AyaNova\models\OpsJobLog.cs, code is 25, comment is 3, blank is 7. +server\AyaNova\models\Tag.cs, code is 19, comment is 1, blank is 5. +server\AyaNova\models\TagMap.cs, code is 23, comment is 0, blank is 4. +server\AyaNova\models\User.cs, code is 24, comment is 0, blank is 1. +server\AyaNova\models\Widget.cs, code is 23, comment is 1, blank is 5. +server\AyaNova\Program.cs, code is 171, comment is 34, blank is 45. +server\AyaNova\Startup.cs, code is 257, comment is 64, blank is 107. +server\AyaNova\SwaggerDefaultValues.cs, code is 30, comment is 12, blank is 5. +server\AyaNova\util\ApplicationLogging.cs, code is 10, comment is 4, blank is 3. +server\AyaNova\util\AyaNovaVersion.cs, code is 22, comment is 5, blank is 4. +server\AyaNova\util\AySchema.cs, code is 173, comment is 38, blank is 71. +server\AyaNova\util\CopyObject.cs, code is 45, comment is 14, blank is 8. +server\AyaNova\util\DateUtil.cs, code is 31, comment is 30, blank is 10. +server\AyaNova\util\DbUtil.cs, code is 307, comment is 72, blank is 98. +server\AyaNova\util\EnumAttributeExtension.cs, code is 23, comment is 11, blank is 2. +server\AyaNova\util\ExceptionUtil.cs, code is 20, comment is 8, blank is 6. +server\AyaNova\util\FileHash.cs, code is 18, comment is 2, blank is 7. +server\AyaNova\util\FileUtil.cs, code is 254, comment is 139, blank is 77. +server\AyaNova\util\Hasher.cs, code is 28, comment is 16, blank is 11. +server\AyaNova\util\IsLocalExtension.cs, code is 23, comment is 4, blank is 5. +server\AyaNova\util\License.cs, code is 405, comment is 114, blank is 110. +server\AyaNova\util\MetricsRegistry.cs, code is 60, comment is 81, blank is 26. +server\AyaNova\util\RetryHelper.cs, code is 29, comment is 14, blank is 11. +server\AyaNova\util\Seeder.cs, code is 158, comment is 61, blank is 75. +server\AyaNova\util\ServerBootConfig.cs, code is 86, comment is 29, blank is 36. +server\AyaNova\util\ServiceProviderProvider.cs, code is 19, comment is 5, blank is 5. +server\AyaNova\util\StringUtil.cs, code is 44, comment is 37, blank is 18. +server\AyaNova\wwwroot\api\sw.css, code is 6, comment is 0, blank is 0. +server\AyaNova\wwwroot\index.htm, code is 48, comment is 3, blank is 10. +startinflux.bat, code is 3, comment is 0, blank is 0. +startsql.bat, code is 2, comment is 0, blank is 0. +startsql.sh, code is 1, comment is 1, blank is 0. +test\raven-integration\ApiResponse.cs, code is 10, comment is 2, blank is 3. +test\raven-integration\ApiTextResponse.cs, code is 10, comment is 2, blank is 3. +test\raven-integration\Attachments\AttachmentTest.cs, code is 90, comment is 45, blank is 50. +test\raven-integration\Authentication\Auth.cs, code is 19, comment is 13, blank is 6. +test\raven-integration\AyaType\AyaType.cs, code is 22, comment is 8, blank is 8. +test\raven-integration\ImportV7\ImportV7.cs, code is 32, comment is 11, blank is 16. +test\raven-integration\JobOperations\JobOperations.cs, code is 30, comment is 16, blank is 15. +test\raven-integration\LogFiles\LogFiles.cs, code is 19, comment is 7, blank is 8. +test\raven-integration\Metrics\Metrics.cs, code is 27, comment is 9, blank is 17. +test\raven-integration\Privacy\Privacy.cs, code is 16, comment is 6, blank is 8. +test\raven-integration\raven-integration.csproj, code is 14, comment is 0, blank is 3. +test\raven-integration\ServerState\ServerStateTest.cs, code is 13, comment is 37, blank is 31. +test\raven-integration\Tags\TagCrud.cs, code is 46, comment is 31, blank is 31. +test\raven-integration\Tags\TagLists.cs, code is 52, comment is 10, blank is 22. +test\raven-integration\Tags\TagMapOps.cs, code is 140, comment is 66, blank is 74. +test\raven-integration\testdata\ayanova.data.dump.xxx.zip, it is a binary file. +test\raven-integration\testdata\test.png, it is a binary file. +test\raven-integration\testdata\test.zip, it is a binary file. +test\raven-integration\util.cs, code is 14, comment is 1, blank is 3. +test\raven-integration\Widget\WidgetCrud.cs, code is 114, comment is 58, blank is 58. +test\raven-integration\Widget\WidgetLists.cs, code is 45, comment is 13, blank is 18. +test\raven-integration\Widget\WidgetRights.cs, code is 139, comment is 62, blank is 62. +test\raven-integration\Widget\WidgetValidationTests.cs, code is 139, comment is 58, blank is 67. +=============================================================================== +=============================================================================== +EXTENSION NAME : linecount +EXTENSION VERSION : 0.1.7 +------------------------------------------------------------------------------- +count time : 2018-06-12 16:10:45 +count workspace : c:\data\code\raven +total files : 155 +total code lines : 8371 +total comment lines : 9285 +total blank lines : 3174 + +dist\docker\linux-x64\ayanovadocker\dockerfile, code is 4, comment is 0, blank is 0. +dist\docker\linux-x64\docker-compose.yml, code is 47, comment is 2, blank is 5. +dist\docker\linux-x64\docker-compose.yml.original.b4.metrics, code is 37, comment is 1, blank is 4. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\docker-compose.yml, code is 15, comment is 0, blank is 3. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\letsencrypt-site\index.html, code is 5, comment is 0, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\nginx.conf, code is 11, comment is 0, blank is 2. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\dh-param\dhparam-2048.pem, code is 8, comment is 1, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\docker-compose.yml, code is 19, comment is 0, blank is 3. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\production-site\index.html, code is 13, comment is 0, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\production.conf, code is 120, comment is 6, blank is 13. +dist\docker\linux-x64\restartnginx.sh, code is 3, comment is 3, blank is 0. +makedocs.bat, code is 3, comment is 0, blank is 0. +makedocs.sh, code is 2, comment is 1, blank is 0. +server\AyaNova\appsettings.Development.json, code is 10, comment is 0, blank is 0. +server\AyaNova\appsettings.json, code is 15, comment is 0, blank is 2. +server\AyaNova\AyaNova.csproj, code is 13, comment is 0, blank is 0. +server\AyaNova\biz\AttachableAttribute.cs, code is 8, comment is 7, blank is 1. +server\AyaNova\biz\AuthorizationRoles.cs, code is 27, comment is 26, blank is 6. +server\AyaNova\biz\AyaObjectOwnerId.cs, code is 27, comment is 7, blank is 16. +server\AyaNova\biz\AyaType.cs, code is 22, comment is 126, blank is 9. +server\AyaNova\biz\AyaTypeId.cs, code is 67, comment is 16, blank is 21. +server\AyaNova\biz\BizObject.cs, code is 52, comment is 9, blank is 24. +server\AyaNova\biz\BizObjectFactory.cs, code is 38, comment is 4, blank is 15. +server\AyaNova\biz\BizRoles.cs, code is 87, comment is 47, blank is 32. +server\AyaNova\biz\BizRoleSet.cs, code is 9, comment is 5, blank is 3. +server\AyaNova\biz\IBizObject.cs, code is 16, comment is 28, blank is 17. +server\AyaNova\biz\IImportAyaNova7Object.cs, code is 12, comment is 10, blank is 6. +server\AyaNova\biz\IJobObject.cs, code is 8, comment is 9, blank is 5. +server\AyaNova\biz\ImportAyaNova7Biz.cs, code is 88, comment is 41, blank is 34. +server\AyaNova\biz\JobOperationsBiz.cs, code is 70, comment is 8, blank is 33. +server\AyaNova\biz\JobsBiz.cs, code is 202, comment is 106, blank is 76. +server\AyaNova\biz\JobStatus.cs, code is 10, comment is 4, blank is 4. +server\AyaNova\biz\JobType.cs, code is 11, comment is 5, blank is 6. +server\AyaNova\biz\LocaleBiz.cs, code is 163, comment is 63, blank is 61. +server\AyaNova\biz\LocalText.cs, code is 105, comment is 5720, blank is 28. +server\AyaNova\biz\TagBiz.cs, code is 159, comment is 56, blank is 62. +server\AyaNova\biz\TaggableAttribute.cs, code is 8, comment is 7, blank is 1. +server\AyaNova\biz\TagMapBiz.cs, code is 86, comment is 51, blank is 46. +server\AyaNova\biz\TrialBiz.cs, code is 49, comment is 26, blank is 24. +server\AyaNova\biz\ValidationError.cs, code is 9, comment is 2, blank is 3. +server\AyaNova\biz\ValidationErrorType.cs, code is 12, comment is 1, blank is 7. +server\AyaNova\biz\WidgetBiz.cs, code is 184, comment is 63, blank is 69. +server\AyaNova\ControllerHelpers\ApiCreatedResponse.cs, code is 11, comment is 2, blank is 8. +server\AyaNova\ControllerHelpers\ApiCustomExceptionFilter.cs, code is 53, comment is 29, blank is 19. +server\AyaNova\ControllerHelpers\ApiDetailError.cs, code is 16, comment is 5, blank is 10. +server\AyaNova\ControllerHelpers\ApiError.cs, code is 21, comment is 5, blank is 12. +server\AyaNova\ControllerHelpers\ApiErrorCode.cs, code is 24, comment is 5, blank is 9. +server\AyaNova\ControllerHelpers\ApiErrorCodeStockMessage.cs, code is 43, comment is 8, blank is 8. +server\AyaNova\ControllerHelpers\ApiErrorResponse.cs, code is 60, comment is 11, blank is 35. +server\AyaNova\ControllerHelpers\ApiNotAuthorizedResponse.cs, code is 20, comment is 4, blank is 11. +server\AyaNova\ControllerHelpers\ApiOkResponse.cs, code is 11, comment is 2, blank is 8. +server\AyaNova\ControllerHelpers\ApiOkWithPagingResponse.cs, code is 14, comment is 7, blank is 11. +server\AyaNova\ControllerHelpers\ApiPagedResponse.cs, code is 15, comment is 2, blank is 9. +server\AyaNova\ControllerHelpers\ApiServerState.cs, code is 115, comment is 39, blank is 38. +server\AyaNova\ControllerHelpers\ApiUploadProcessor.cs, code is 124, comment is 45, blank is 42. +server\AyaNova\ControllerHelpers\Authorized.cs, code is 55, comment is 35, blank is 32. +server\AyaNova\ControllerHelpers\DisableFormValueModelBindingAttribute.cs, code is 31, comment is 14, blank is 5. +server\AyaNova\ControllerHelpers\MultipartRequestHelper.cs, code is 42, comment is 28, blank is 7. +server\AyaNova\ControllerHelpers\PaginationLinkBuilder.cs, code is 63, comment is 7, blank is 20. +server\AyaNova\ControllerHelpers\PagingOptions.cs, code is 17, comment is 0, blank is 6. +server\AyaNova\ControllerHelpers\UserIdFromContext.cs, code is 15, comment is 1, blank is 6. +server\AyaNova\ControllerHelpers\UserNameFromContext.cs, code is 15, comment is 1, blank is 5. +server\AyaNova\ControllerHelpers\UserRolesFromContext.cs, code is 13, comment is 1, blank is 5. +server\AyaNova\Controllers\ApiRootController.cs, code is 29, comment is 11, blank is 12. +server\AyaNova\Controllers\AttachmentController.cs, code is 245, comment is 136, blank is 75. +server\AyaNova\Controllers\AuthController.cs, code is 97, comment is 46, blank is 25. +server\AyaNova\Controllers\AyaTypeController.cs, code is 48, comment is 15, blank is 20. +server\AyaNova\Controllers\BackupController.cs, code is 38, comment is 122, blank is 34. +server\AyaNova\Controllers\ImportAyaNova7Controller.cs, code is 166, comment is 73, blank is 48. +server\AyaNova\Controllers\JobOperationsController.cs, code is 68, comment is 35, blank is 33. +server\AyaNova\Controllers\LicenseController.cs, code is 114, comment is 46, blank is 39. +server\AyaNova\Controllers\LocaleController.cs, code is 97, comment is 200, blank is 75. +server\AyaNova\Controllers\LogFilesController.cs, code is 78, comment is 62, blank is 37. +server\AyaNova\Controllers\MetricsController.cs, code is 79, comment is 37, blank is 21. +server\AyaNova\Controllers\ServerStateController.cs, code is 60, comment is 40, blank is 21. +server\AyaNova\Controllers\TagController.cs, code is 216, comment is 82, blank is 69. +server\AyaNova\Controllers\TagMapController.cs, code is 147, comment is 51, blank is 62. +server\AyaNova\Controllers\TrialController.cs, code is 69, comment is 41, blank is 18. +server\AyaNova\Controllers\WidgetController.cs, code is 279, comment is 116, blank is 89. +server\AyaNova\generator\BackgroundService.cs, code is 43, comment is 13, blank is 16. +server\AyaNova\generator\CoreJobMetricsReport.cs, code is 23, comment is 15, blank is 19. +server\AyaNova\generator\CoreJobMetricsSnapshot.cs, code is 70, comment is 26, blank is 40. +server\AyaNova\generator\CoreJobSweeper.cs, code is 74, comment is 30, blank is 28. +server\AyaNova\generator\Generate.cs, code is 65, comment is 27, blank is 32. +server\AyaNova\logs\log-ayanova.txt, code is 10, comment is 0, blank is 0. +server\AyaNova\models\AyContext.cs, code is 59, comment is 11, blank is 22. +server\AyaNova\models\dto\ImportV7MapItem.cs, code is 14, comment is 3, blank is 3. +server\AyaNova\models\dto\JobOperationsFetchInfo.cs, code is 13, comment is 23, blank is 4. +server\AyaNova\models\dto\JobOperationsLogInfoItem.cs, code is 10, comment is 11, blank is 5. +server\AyaNova\models\dto\NameItem.cs, code is 7, comment is 3, blank is 2. +server\AyaNova\models\dto\NameValueActiveItem.cs, code is 9, comment is 0, blank is 3. +server\AyaNova\models\dto\NameValueItem.cs, code is 8, comment is 0, blank is 2. +server\AyaNova\models\dto\TagMapInfo.cs, code is 10, comment is 0, blank is 4. +server\AyaNova\models\dto\TypeAndIdInfo.cs, code is 9, comment is 0, blank is 4. +server\AyaNova\models\dto\UploadedFileInfo.cs, code is 9, comment is 3, blank is 2. +server\AyaNova\models\FileAttachment.cs, code is 25, comment is 5, blank is 4. +server\AyaNova\models\License.cs, code is 14, comment is 0, blank is 6. +server\AyaNova\models\Locale.cs, code is 18, comment is 1, blank is 5. +server\AyaNova\models\LocaleItem.cs, code is 17, comment is 1, blank is 6. +server\AyaNova\models\OpsJob.cs, code is 42, comment is 9, blank is 11. +server\AyaNova\models\OpsJobLog.cs, code is 23, comment is 3, blank is 9. +server\AyaNova\models\Tag.cs, code is 17, comment is 1, blank is 7. +server\AyaNova\models\TagMap.cs, code is 21, comment is 0, blank is 6. +server\AyaNova\models\User.cs, code is 23, comment is 0, blank is 4. +server\AyaNova\models\Widget.cs, code is 21, comment is 1, blank is 7. +server\AyaNova\Program.cs, code is 170, comment is 35, blank is 47. +server\AyaNova\Startup.cs, code is 253, comment is 71, blank is 114. +server\AyaNova\SwaggerDefaultValues.cs, code is 30, comment is 12, blank is 5. +server\AyaNova\util\ApplicationLogging.cs, code is 10, comment is 4, blank is 3. +server\AyaNova\util\AyaNovaVersion.cs, code is 20, comment is 5, blank is 6. +server\AyaNova\util\AySchema.cs, code is 185, comment is 43, blank is 80. +server\AyaNova\util\CopyObject.cs, code is 43, comment is 14, blank is 10. +server\AyaNova\util\DateUtil.cs, code is 29, comment is 30, blank is 12. +server\AyaNova\util\DbUtil.cs, code is 307, comment is 73, blank is 102. +server\AyaNova\util\EnumAttributeExtension.cs, code is 23, comment is 11, blank is 2. +server\AyaNova\util\ExceptionUtil.cs, code is 18, comment is 8, blank is 8. +server\AyaNova\util\FileHash.cs, code is 18, comment is 2, blank is 7. +server\AyaNova\util\FileUtil.cs, code is 254, comment is 139, blank is 77. +server\AyaNova\util\Hasher.cs, code is 26, comment is 16, blank is 13. +server\AyaNova\util\IsLocalExtension.cs, code is 21, comment is 4, blank is 6. +server\AyaNova\util\License.cs, code is 405, comment is 119, blank is 115. +server\AyaNova\util\MetricsRegistry.cs, code is 60, comment is 81, blank is 26. +server\AyaNova\util\RetryHelper.cs, code is 29, comment is 14, blank is 11. +server\AyaNova\util\Seeder.cs, code is 156, comment is 61, blank is 77. +server\AyaNova\util\ServerBootConfig.cs, code is 87, comment is 31, blank is 39. +server\AyaNova\util\ServiceProviderProvider.cs, code is 19, comment is 5, blank is 5. +server\AyaNova\util\StringUtil.cs, code is 42, comment is 37, blank is 20. +server\AyaNova\wwwroot\api\sw.css, code is 6, comment is 0, blank is 0. +server\AyaNova\wwwroot\index.htm, code is 48, comment is 3, blank is 10. +startinflux.bat, code is 3, comment is 0, blank is 0. +startsql.bat, code is 1, comment is 0, blank is 0. +startsql.sh, code is 1, comment is 1, blank is 0. +test\raven-integration\ApiResponse.cs, code is 10, comment is 2, blank is 3. +test\raven-integration\ApiTextResponse.cs, code is 10, comment is 2, blank is 3. +test\raven-integration\Attachments\AttachmentTest.cs, code is 90, comment is 45, blank is 50. +test\raven-integration\Authentication\Auth.cs, code is 19, comment is 13, blank is 6. +test\raven-integration\AyaType\AyaType.cs, code is 22, comment is 8, blank is 8. +test\raven-integration\ImportV7\ImportV7.cs, code is 32, comment is 11, blank is 16. +test\raven-integration\JobOperations\JobOperations.cs, code is 30, comment is 16, blank is 15. +test\raven-integration\LogFiles\LogFiles.cs, code is 19, comment is 7, blank is 8. +test\raven-integration\Metrics\Metrics.cs, code is 27, comment is 9, blank is 17. +test\raven-integration\Privacy\Privacy.cs, code is 16, comment is 6, blank is 8. +test\raven-integration\raven-integration.csproj, code is 14, comment is 0, blank is 3. +test\raven-integration\ServerState\ServerStateTest.cs, code is 13, comment is 37, blank is 31. +test\raven-integration\Tags\TagCrud.cs, code is 46, comment is 31, blank is 31. +test\raven-integration\Tags\TagLists.cs, code is 52, comment is 10, blank is 22. +test\raven-integration\Tags\TagMapOps.cs, code is 140, comment is 66, blank is 74. +test\raven-integration\testdata\ayanova.data.dump.xxx.zip, it is a binary file. +test\raven-integration\testdata\test.png, it is a binary file. +test\raven-integration\testdata\test.zip, it is a binary file. +test\raven-integration\util.cs, code is 14, comment is 1, blank is 3. +test\raven-integration\Widget\WidgetCrud.cs, code is 114, comment is 58, blank is 58. +test\raven-integration\Widget\WidgetLists.cs, code is 45, comment is 13, blank is 18. +test\raven-integration\Widget\WidgetRights.cs, code is 139, comment is 62, blank is 62. +test\raven-integration\Widget\WidgetValidationTests.cs, code is 139, comment is 58, blank is 67. +=============================================================================== +=============================================================================== +EXTENSION NAME : linecount +EXTENSION VERSION : 0.1.7 +------------------------------------------------------------------------------- +count time : 2018-06-22 11:38:36 +count workspace : c:\data\code\raven +total files : 156 +total code lines : 8581 +total comment lines : 3560 +total blank lines : 3238 + +dist\docker\linux-x64\ayanovadocker\dockerfile, code is 4, comment is 0, blank is 0. +dist\docker\linux-x64\docker-compose.yml, code is 47, comment is 2, blank is 5. +dist\docker\linux-x64\docker-compose.yml.original.b4.metrics, code is 37, comment is 1, blank is 4. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\docker-compose.yml, code is 15, comment is 0, blank is 3. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\letsencrypt-site\index.html, code is 5, comment is 0, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\letsencrypt\nginx.conf, code is 11, comment is 0, blank is 2. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\dh-param\dhparam-2048.pem, code is 8, comment is 1, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\docker-compose.yml, code is 19, comment is 0, blank is 3. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\production-site\index.html, code is 13, comment is 0, blank is 0. +dist\docker\linux-x64\host\docker-nginx-ayanova-sample-config\production\production.conf, code is 120, comment is 6, blank is 13. +dist\docker\linux-x64\restartnginx.sh, code is 3, comment is 3, blank is 0. +makedocs.bat, code is 3, comment is 0, blank is 0. +makedocs.sh, code is 2, comment is 1, blank is 0. +server\AyaNova\appsettings.Development.json, code is 10, comment is 0, blank is 0. +server\AyaNova\appsettings.json, code is 15, comment is 0, blank is 2. +server\AyaNova\AyaNova.csproj, code is 13, comment is 0, blank is 1. +server\AyaNova\biz\AttachableAttribute.cs, code is 8, comment is 7, blank is 1. +server\AyaNova\biz\AuthorizationRoles.cs, code is 27, comment is 26, blank is 6. +server\AyaNova\biz\AyaObjectOwnerId.cs, code is 27, comment is 7, blank is 16. +server\AyaNova\biz\AyaType.cs, code is 22, comment is 126, blank is 9. +server\AyaNova\biz\AyaTypeId.cs, code is 67, comment is 16, blank is 21. +server\AyaNova\biz\BizObject.cs, code is 52, comment is 9, blank is 24. +server\AyaNova\biz\BizObjectFactory.cs, code is 38, comment is 4, blank is 15. +server\AyaNova\biz\BizRoles.cs, code is 87, comment is 47, blank is 32. +server\AyaNova\biz\BizRoleSet.cs, code is 9, comment is 5, blank is 3. +server\AyaNova\biz\IBizObject.cs, code is 16, comment is 28, blank is 17. +server\AyaNova\biz\IImportAyaNova7Object.cs, code is 12, comment is 10, blank is 6. +server\AyaNova\biz\IJobObject.cs, code is 8, comment is 9, blank is 5. +server\AyaNova\biz\ImportAyaNova7Biz.cs, code is 89, comment is 40, blank is 36. +server\AyaNova\biz\JobOperationsBiz.cs, code is 70, comment is 8, blank is 33. +server\AyaNova\biz\JobsBiz.cs, code is 202, comment is 106, blank is 76. +server\AyaNova\biz\JobStatus.cs, code is 10, comment is 4, blank is 4. +server\AyaNova\biz\JobType.cs, code is 11, comment is 5, blank is 6. +server\AyaNova\biz\LocaleBiz.cs, code is 270, comment is 80, blank is 85. +server\AyaNova\biz\PrimeData.cs, code is 61, comment is 21, blank is 27. +server\AyaNova\biz\TagBiz.cs, code is 159, comment is 56, blank is 62. +server\AyaNova\biz\TaggableAttribute.cs, code is 8, comment is 7, blank is 1. +server\AyaNova\biz\TagMapBiz.cs, code is 85, comment is 51, blank is 46. +server\AyaNova\biz\TrialBiz.cs, code is 49, comment is 26, blank is 24. +server\AyaNova\biz\ValidationError.cs, code is 9, comment is 2, blank is 3. +server\AyaNova\biz\ValidationErrorType.cs, code is 13, comment is 2, blank is 8. +server\AyaNova\biz\WidgetBiz.cs, code is 184, comment is 63, blank is 69. +server\AyaNova\ControllerHelpers\ApiCreatedResponse.cs, code is 11, comment is 2, blank is 8. +server\AyaNova\ControllerHelpers\ApiCustomExceptionFilter.cs, code is 53, comment is 29, blank is 19. +server\AyaNova\ControllerHelpers\ApiDetailError.cs, code is 16, comment is 5, blank is 10. +server\AyaNova\ControllerHelpers\ApiError.cs, code is 21, comment is 5, blank is 12. +server\AyaNova\ControllerHelpers\ApiErrorCode.cs, code is 24, comment is 5, blank is 9. +server\AyaNova\ControllerHelpers\ApiErrorCodeStockMessage.cs, code is 43, comment is 8, blank is 8. +server\AyaNova\ControllerHelpers\ApiErrorResponse.cs, code is 60, comment is 11, blank is 35. +server\AyaNova\ControllerHelpers\ApiNotAuthorizedResponse.cs, code is 20, comment is 4, blank is 11. +server\AyaNova\ControllerHelpers\ApiOkResponse.cs, code is 11, comment is 2, blank is 8. +server\AyaNova\ControllerHelpers\ApiOkWithPagingResponse.cs, code is 14, comment is 7, blank is 11. +server\AyaNova\ControllerHelpers\ApiPagedResponse.cs, code is 15, comment is 2, blank is 9. +server\AyaNova\ControllerHelpers\ApiServerState.cs, code is 115, comment is 39, blank is 38. +server\AyaNova\ControllerHelpers\ApiUploadProcessor.cs, code is 124, comment is 45, blank is 42. +server\AyaNova\ControllerHelpers\Authorized.cs, code is 55, comment is 35, blank is 32. +server\AyaNova\ControllerHelpers\DisableFormValueModelBindingAttribute.cs, code is 31, comment is 14, blank is 5. +server\AyaNova\ControllerHelpers\MultipartRequestHelper.cs, code is 42, comment is 28, blank is 7. +server\AyaNova\ControllerHelpers\PaginationLinkBuilder.cs, code is 63, comment is 7, blank is 20. +server\AyaNova\ControllerHelpers\PagingOptions.cs, code is 17, comment is 0, blank is 6. +server\AyaNova\ControllerHelpers\UserIdFromContext.cs, code is 15, comment is 1, blank is 6. +server\AyaNova\ControllerHelpers\UserNameFromContext.cs, code is 15, comment is 1, blank is 5. +server\AyaNova\ControllerHelpers\UserRolesFromContext.cs, code is 13, comment is 1, blank is 5. +server\AyaNova\Controllers\ApiRootController.cs, code is 29, comment is 11, blank is 11. +server\AyaNova\Controllers\AttachmentController.cs, code is 245, comment is 136, blank is 75. +server\AyaNova\Controllers\AuthController.cs, code is 97, comment is 46, blank is 25. +server\AyaNova\Controllers\AyaTypeController.cs, code is 48, comment is 15, blank is 20. +server\AyaNova\Controllers\BackupController.cs, code is 38, comment is 127, blank is 40. +server\AyaNova\Controllers\ImportAyaNova7Controller.cs, code is 166, comment is 73, blank is 48. +server\AyaNova\Controllers\JobOperationsController.cs, code is 68, comment is 35, blank is 33. +server\AyaNova\Controllers\LicenseController.cs, code is 114, comment is 46, blank is 39. +server\AyaNova\Controllers\LocaleController.cs, code is 127, comment is 65, blank is 57. +server\AyaNova\Controllers\LogFilesController.cs, code is 78, comment is 62, blank is 37. +server\AyaNova\Controllers\MetricsController.cs, code is 79, comment is 37, blank is 21. +server\AyaNova\Controllers\ServerStateController.cs, code is 60, comment is 40, blank is 21. +server\AyaNova\Controllers\TagController.cs, code is 216, comment is 82, blank is 69. +server\AyaNova\Controllers\TagMapController.cs, code is 147, comment is 51, blank is 62. +server\AyaNova\Controllers\TrialController.cs, code is 69, comment is 41, blank is 18. +server\AyaNova\Controllers\WidgetController.cs, code is 279, comment is 116, blank is 89. +server\AyaNova\generator\BackgroundService.cs, code is 43, comment is 13, blank is 16. +server\AyaNova\generator\CoreJobMetricsReport.cs, code is 23, comment is 15, blank is 19. +server\AyaNova\generator\CoreJobMetricsSnapshot.cs, code is 70, comment is 26, blank is 40. +server\AyaNova\generator\CoreJobSweeper.cs, code is 74, comment is 30, blank is 28. +server\AyaNova\generator\Generate.cs, code is 65, comment is 27, blank is 32. +server\AyaNova\logs\log-ayanova.txt, code is 23, comment is 0, blank is 0. +server\AyaNova\models\AyContext.cs, code is 59, comment is 11, blank is 22. +server\AyaNova\models\dto\ImportV7MapItem.cs, code is 14, comment is 3, blank is 3. +server\AyaNova\models\dto\JobOperationsFetchInfo.cs, code is 13, comment is 23, blank is 4. +server\AyaNova\models\dto\JobOperationsLogInfoItem.cs, code is 10, comment is 11, blank is 5. +server\AyaNova\models\dto\NameIdActiveItem.cs, code is 9, comment is 0, blank is 3. +server\AyaNova\models\dto\NameIdItem.cs, code is 8, comment is 0, blank is 2. +server\AyaNova\models\dto\NameItem.cs, code is 7, comment is 3, blank is 2. +server\AyaNova\models\dto\TagMapInfo.cs, code is 10, comment is 0, blank is 4. +server\AyaNova\models\dto\TypeAndIdInfo.cs, code is 9, comment is 0, blank is 4. +server\AyaNova\models\dto\UploadedFileInfo.cs, code is 9, comment is 3, blank is 2. +server\AyaNova\models\FileAttachment.cs, code is 29, comment is 5, blank is 6. +server\AyaNova\models\License.cs, code is 14, comment is 0, blank is 6. +server\AyaNova\models\Locale.cs, code is 31, comment is 9, blank is 14. +server\AyaNova\models\LocaleItem.cs, code is 20, comment is 1, blank is 7. +server\AyaNova\models\OpsJob.cs, code is 42, comment is 9, blank is 11. +server\AyaNova\models\OpsJobLog.cs, code is 23, comment is 3, blank is 9. +server\AyaNova\models\Tag.cs, code is 21, comment is 1, blank is 9. +server\AyaNova\models\TagMap.cs, code is 25, comment is 0, blank is 8. +server\AyaNova\models\User.cs, code is 27, comment is 0, blank is 5. +server\AyaNova\models\Widget.cs, code is 25, comment is 1, blank is 9. +server\AyaNova\Program.cs, code is 170, comment is 35, blank is 47. +server\AyaNova\Startup.cs, code is 252, comment is 73, blank is 117. +server\AyaNova\SwaggerDefaultValues.cs, code is 30, comment is 12, blank is 5. +server\AyaNova\util\ApplicationLogging.cs, code is 10, comment is 4, blank is 3. +server\AyaNova\util\AyaNovaVersion.cs, code is 20, comment is 5, blank is 6. +server\AyaNova\util\AySchema.cs, code is 162, comment is 62, blank is 80. +server\AyaNova\util\CopyObject.cs, code is 43, comment is 14, blank is 10. +server\AyaNova\util\DateUtil.cs, code is 29, comment is 30, blank is 12. +server\AyaNova\util\DbUtil.cs, code is 306, comment is 76, blank is 100. +server\AyaNova\util\EnumAttributeExtension.cs, code is 23, comment is 11, blank is 2. +server\AyaNova\util\ExceptionUtil.cs, code is 18, comment is 8, blank is 8. +server\AyaNova\util\FileHash.cs, code is 18, comment is 2, blank is 7. +server\AyaNova\util\FileUtil.cs, code is 254, comment is 140, blank is 78. +server\AyaNova\util\Hasher.cs, code is 26, comment is 16, blank is 13. +server\AyaNova\util\IsLocalExtension.cs, code is 21, comment is 4, blank is 6. +server\AyaNova\util\License.cs, code is 405, comment is 119, blank is 115. +server\AyaNova\util\MetricsRegistry.cs, code is 60, comment is 81, blank is 26. +server\AyaNova\util\RetryHelper.cs, code is 29, comment is 14, blank is 11. +server\AyaNova\util\Seeder.cs, code is 144, comment is 71, blank is 73. +server\AyaNova\util\ServerBootConfig.cs, code is 115, comment is 35, blank is 44. +server\AyaNova\util\ServiceProviderProvider.cs, code is 35, comment is 12, blank is 11. +server\AyaNova\util\StringUtil.cs, code is 42, comment is 37, blank is 20. +server\AyaNova\wwwroot\api\sw.css, code is 6, comment is 0, blank is 0. +server\AyaNova\wwwroot\index.htm, code is 48, comment is 3, blank is 10. +startinflux.bat, code is 3, comment is 0, blank is 0. +startsql.bat, code is 1, comment is 0, blank is 0. +startsql.sh, code is 1, comment is 1, blank is 0. +test\raven-integration\ApiResponse.cs, code is 10, comment is 2, blank is 3. +test\raven-integration\ApiTextResponse.cs, code is 10, comment is 2, blank is 3. +test\raven-integration\Attachments\AttachmentTest.cs, code is 90, comment is 45, blank is 50. +test\raven-integration\Authentication\Auth.cs, code is 19, comment is 13, blank is 6. +test\raven-integration\AyaType\AyaType.cs, code is 22, comment is 8, blank is 8. +test\raven-integration\ImportV7\ImportV7.cs, code is 32, comment is 11, blank is 16. +test\raven-integration\JobOperations\JobOperations.cs, code is 30, comment is 16, blank is 15. +test\raven-integration\Locale\Locale.cs, code is 60, comment is 33, blank is 22. +test\raven-integration\LogFiles\LogFiles.cs, code is 19, comment is 7, blank is 8. +test\raven-integration\Metrics\Metrics.cs, code is 27, comment is 9, blank is 17. +test\raven-integration\Privacy\Privacy.cs, code is 16, comment is 6, blank is 8. +test\raven-integration\raven-integration.csproj, code is 14, comment is 0, blank is 3. +test\raven-integration\ServerState\ServerStateTest.cs, code is 13, comment is 37, blank is 31. +test\raven-integration\Tags\TagCrud.cs, code is 46, comment is 31, blank is 31. +test\raven-integration\Tags\TagLists.cs, code is 52, comment is 10, blank is 22. +test\raven-integration\Tags\TagMapOps.cs, code is 140, comment is 66, blank is 74. +test\raven-integration\testdata\ayanova.data.dump.xxx.zip, it is a binary file. +test\raven-integration\testdata\test.png, it is a binary file. +test\raven-integration\testdata\test.zip, it is a binary file. +test\raven-integration\util.cs, code is 14, comment is 1, blank is 3. +test\raven-integration\Widget\WidgetCrud.cs, code is 114, comment is 58, blank is 58. +test\raven-integration\Widget\WidgetLists.cs, code is 45, comment is 13, blank is 18. +test\raven-integration\Widget\WidgetRights.cs, code is 139, comment is 62, blank is 62. +test\raven-integration\Widget\WidgetValidationTests.cs, code is 139, comment is 58, blank is 67. +=============================================================================== diff --git a/makedocs.bat b/makedocs.bat new file mode 100644 index 00000000..a0d9e807 --- /dev/null +++ b/makedocs.bat @@ -0,0 +1,3 @@ +cd c:\data\code\raven\docs\8.0\ayanova +mkdocs build +cd ..\..\.. diff --git a/makedocs.sh b/makedocs.sh new file mode 100644 index 00000000..c2ac440b --- /dev/null +++ b/makedocs.sh @@ -0,0 +1,3 @@ +#!/bin/bash +cd ~/Documents/raven/docs/8.0/ayanova +python -m mkdocs build \ No newline at end of file diff --git a/server/AyaNova/AyaNova.csproj b/server/AyaNova/AyaNova.csproj new file mode 100644 index 00000000..d66429fe --- /dev/null +++ b/server/AyaNova/AyaNova.csproj @@ -0,0 +1,42 @@ + + + netcoreapp2.1 + + + true + 8.0.0-alpha + 8.0.0.0 + bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml + 1591 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiCreatedResponse.cs b/server/AyaNova/ControllerHelpers/ApiCreatedResponse.cs new file mode 100644 index 00000000..cc4ccb30 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiCreatedResponse.cs @@ -0,0 +1,19 @@ + +namespace AyaNova.Api.ControllerHelpers +{ + + + + public class ApiCreatedResponse + { + + public object Result { get; } + + public ApiCreatedResponse(object result) + { + Result = result; + } + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiCustomExceptionFilter.cs b/server/AyaNova/ControllerHelpers/ApiCustomExceptionFilter.cs new file mode 100644 index 00000000..19037df0 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiCustomExceptionFilter.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Http; +using System; +using System.Net; +using System.Net.Http; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Microsoft.Extensions.Logging; +using App.Metrics; +using AyaNova.Util; + +namespace AyaNova.Api.ControllerHelpers +{ + + + /// + /// This is essentially an unhandled exception handler + /// + public class ApiCustomExceptionFilter : IExceptionFilter + { + private readonly ILogger log; + + public ApiCustomExceptionFilter(ILoggerFactory logger) + { + if (logger == null) + { + throw new ArgumentNullException(nameof(logger)); + } + + this.log = logger.CreateLogger("Server Exception"); + } + + + public void OnException(ExceptionContext context) + { + HttpStatusCode status = HttpStatusCode.InternalServerError; + String message = String.Empty; + #region If need to refine this further and deal with specific types + // var exceptionType = context.Exception.GetType(); + // if (exceptionType == typeof(UnauthorizedAccessException)) + // { + // message = "Unauthorized Access"; + // status = HttpStatusCode.Unauthorized; + // } + // else if (exceptionType == typeof(NotImplementedException)) + // { + // message = "A server error occurred."; + // status = HttpStatusCode.NotImplemented; + // } + // // else if (exceptionType == typeof(MyAppException)) + // // { + // // message = context.Exception.ToString(); + // // status = HttpStatusCode.InternalServerError; + // // } + // else + // { + #endregion + message = context.Exception.Message; + status = HttpStatusCode.InternalServerError; + //} + + //No need to log test exceptions to check and filter out + bool loggableError = true; + + if (message.StartsWith("Test exception")) + loggableError = false; + + //LOG IT + if (loggableError) + log.LogError(context.Exception, "Error"); + + + //Track this exception + IMetrics metrics = (IMetrics)ServiceProviderProvider.Provider.GetService(typeof(IMetrics)); + metrics.Measure.Meter.Mark(MetricsRegistry.UnhandledExceptionsMeter,context.Exception.GetType().ToString()); + + + HttpResponse response = context.HttpContext.Response; + response.StatusCode = (int)status; + response.ContentType = "application/json; charset=utf-8"; + + //This line is critical, without it the response is not proper and fails in various clients (postman, xunit tests with httpclient) + context.ExceptionHandled = true; + //context.Result + + response.WriteAsync(JsonConvert.SerializeObject( + new ApiErrorResponse(ApiErrorCode.API_SERVER_ERROR, "Server internal error", "See server log for details"), + new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + } + )); + } + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiDetailError.cs b/server/AyaNova/ControllerHelpers/ApiDetailError.cs new file mode 100644 index 00000000..5a8f2752 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiDetailError.cs @@ -0,0 +1,29 @@ +using Newtonsoft.Json; +using AyaNova.Biz; + +namespace AyaNova.Api.ControllerHelpers +{ + + + /// + /// Detail error for inner part of error response + /// + public class ApiDetailError + { + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Code { get; internal set; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; internal set; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Target { get; internal set; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Error { get; internal set; } + + + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiError.cs b/server/AyaNova/ControllerHelpers/ApiError.cs new file mode 100644 index 00000000..93d1eecb --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiError.cs @@ -0,0 +1,36 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace AyaNova.Api.ControllerHelpers +{ + + + + + /// + /// + /// + public class ApiError + { + public string Code { get; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public List Details { get; internal set; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Message { get; } + + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Target { get; } + + public ApiError(ApiErrorCode apiCode, string message = null, string target = null) + { + Code = ((int)apiCode).ToString(); + Target = target; + Message=message; + } + + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiErrorCode.cs b/server/AyaNova/ControllerHelpers/ApiErrorCode.cs new file mode 100644 index 00000000..e0dd1aee --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiErrorCode.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using System.Linq; +using System.Collections.Generic; + + +namespace AyaNova.Api.ControllerHelpers +{ + + + public enum ApiErrorCode : int + { + /* + DON'T FORGET TO UPDATE THE API-ERROR-CODES.MD DOCUMENTATION + AND UPDATE THE ApiErrorCodeStockMessage.cs + */ + + API_CLOSED = 2000, + API_OPS_ONLY = 2001, + API_SERVER_ERROR = 2002, + AUTHENTICATION_FAILED = 2003, + NOT_AUTHORIZED = 2004, + CONCURRENCY_CONFLICT=2005, + NOT_FOUND = 2010, + PUT_ID_MISMATCH = 2020, + INVALID_OPERATION = 2030, + VALIDATION_FAILED = 2200, + VALIDATION_REQUIRED = 2201, + VALIDATION_LENGTH_EXCEEDED = 2202, + VALIDATION_INVALID_VALUE = 2203 + + + } + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiErrorCodeStockMessage.cs b/server/AyaNova/ControllerHelpers/ApiErrorCodeStockMessage.cs new file mode 100644 index 00000000..4a500782 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiErrorCodeStockMessage.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using System.Linq; +using System.Collections.Generic; + + +namespace AyaNova.Api.ControllerHelpers +{ + + + internal static class ApiErrorCodeStockMessage + { + internal static string GetMessage(ApiErrorCode code) + { + switch (code) + { + case ApiErrorCode.API_CLOSED: + return "API Closed"; + case ApiErrorCode.API_OPS_ONLY: + return "API Closed to non operations routes"; + case ApiErrorCode.CONCURRENCY_CONFLICT: + return "Object was changed by another user since retrieval (concurrency token mismatch)"; + case ApiErrorCode.NOT_FOUND: + return "Object not found"; + case ApiErrorCode.VALIDATION_FAILED: + return "Object did not pass validation"; + case ApiErrorCode.VALIDATION_REQUIRED: + return "Required field empty"; + case ApiErrorCode.VALIDATION_LENGTH_EXCEEDED: + return "Field too long"; + case ApiErrorCode.VALIDATION_INVALID_VALUE: + return "Field is set to a non allowed value"; + case ApiErrorCode.AUTHENTICATION_FAILED: + return "Authentication failed"; + case ApiErrorCode.PUT_ID_MISMATCH: + return "Update failed: ID mismatch - route ID doesn't match object id"; + case ApiErrorCode.INVALID_OPERATION: + return "An attempt was made to perform an invalid operation"; + case ApiErrorCode.NOT_AUTHORIZED: + return "User not authorized for this resource operation (insufficient rights)"; + + default: + return null; + + } + } + /* + VALIDATION_FAILED = 2200, + VALIDATION_REQUIRED = 2201, + VALIDATION_LENGTH_EXCEEDED = 2202, + VALIDATION_INVALID_VALUE = 2203 + + */ + } + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiErrorResponse.cs b/server/AyaNova/ControllerHelpers/ApiErrorResponse.cs new file mode 100644 index 00000000..15113ca6 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiErrorResponse.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using AyaNova.Biz; + +namespace AyaNova.Api.ControllerHelpers +{ + + + + public class ApiErrorResponse + { + + [JsonIgnore] + private ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + + //Mandatory properties + public ApiError Error { get; } + + + + + //Generic error + public ApiErrorResponse(ApiErrorCode apiCode, string target = null, string message = null) + { + + //try to get a stock message if nothing specified + if (message == null) + { + message = ApiErrorCodeStockMessage.GetMessage(apiCode); + } + + Error = new ApiError(apiCode, message, target); + + log.LogDebug("apiCode={0}, target={1}, message={2}", apiCode, target, message); + } + + + //Bad request error response handling + + public ApiErrorResponse(ModelStateDictionary modelState) + { + + if (modelState.IsValid) + { + throw new ArgumentException("ModelState must be invalid", nameof(modelState)); + } + + + //Set outer error and then put validation in details + + Error = new ApiError(ApiErrorCode.VALIDATION_FAILED, ApiErrorCodeStockMessage.GetMessage(ApiErrorCode.VALIDATION_FAILED)); + + + //https://www.jerriepelser.com/blog/validation-response-aspnet-core-webapi/ + //Message = "Validation Failed"; + Error.Details = new List(); + Error.Details.AddRange(modelState.Keys + .SelectMany(key => modelState[key].Errors + .Select(x => new ApiDetailError() { Code = ((int)ApiErrorCode.VALIDATION_FAILED).ToString(), Target = key, Message = x.ErrorMessage, Error=ApiErrorCode.VALIDATION_FAILED.ToString() }))); + + + log.LogDebug("BadRequest - Validation error"); + } + + + //Business rule validation error response + public ApiErrorResponse(List errors) + { + Error = new ApiError(ApiErrorCode.VALIDATION_FAILED, ApiErrorCodeStockMessage.GetMessage(ApiErrorCode.VALIDATION_FAILED)); + Error.Details = new List(); + foreach (ValidationError v in errors) + { + Error.Details.Add(new ApiDetailError() { Target = v.Target, Message = v.Message, Error = v.ErrorType.ToString() }); + } + log.LogDebug("BadRequest - Validation error"); + } + + + + public void AddDetailError(ApiErrorCode apiCode, string target = null, string message = null) + { + if (Error.Details == null) + { + Error.Details = new List(); + } + + //try to get a stock message if nothing specified + if (message == null) + { + message = ApiErrorCodeStockMessage.GetMessage(apiCode); + } + + Error.Details.Add(new ApiDetailError() { Code = ((int)apiCode).ToString(), Target = target, Message = message }); + } + + + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiNotAuthorizedResponse.cs b/server/AyaNova/ControllerHelpers/ApiNotAuthorizedResponse.cs new file mode 100644 index 00000000..4136e7ff --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiNotAuthorizedResponse.cs @@ -0,0 +1,33 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; + +namespace AyaNova.Api.ControllerHelpers +{ + + + + public class ApiNotAuthorizedResponse + { + + [JsonIgnore] + private ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + + //Mandatory properties + public ApiError Error { get; } + + //Generic error + public ApiNotAuthorizedResponse() + { + Error = new ApiError(ApiErrorCode.NOT_AUTHORIZED, ApiErrorCodeStockMessage.GetMessage(ApiErrorCode.NOT_AUTHORIZED)); + + log.LogDebug("ApiErrorCode={0}, message={1}", (int)ApiErrorCode.NOT_AUTHORIZED, Error.Message); + } + + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiOkResponse.cs b/server/AyaNova/ControllerHelpers/ApiOkResponse.cs new file mode 100644 index 00000000..ac1f02a2 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiOkResponse.cs @@ -0,0 +1,19 @@ + +namespace AyaNova.Api.ControllerHelpers +{ + + + + public class ApiOkResponse + { + + public object Result { get; } + + public ApiOkResponse(object result) + { + Result = result; + } + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiOkWithPagingResponse.cs b/server/AyaNova/ControllerHelpers/ApiOkWithPagingResponse.cs new file mode 100644 index 00000000..b46c1d13 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiOkWithPagingResponse.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace AyaNova.Api.ControllerHelpers +{ + + + + public class ApiOkWithPagingResponse + { + + public object Result { get; } + public object Paging { get; } + + public ApiOkWithPagingResponse(ApiPagedResponse pr) + { + Result = pr.items; + Paging = pr.PageLinks; + + } + + // public ApiOkWithPagingResponse(object result, AyaNova.Models.PaginationLinkBuilder lb) + // { + // Result = result; + // Paging = lb.PagingData(); + + // } + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiPagedResponse.cs b/server/AyaNova/ControllerHelpers/ApiPagedResponse.cs new file mode 100644 index 00000000..26bf6b17 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiPagedResponse.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using AyaNova.Models; + +namespace AyaNova.Api.ControllerHelpers +{ + + + + public class ApiPagedResponse + { + + public T[] items { get; } + public object PageLinks { get; } + + public ApiPagedResponse(T[] returnItems, object pageLinks) + { + items = returnItems; + PageLinks = pageLinks; + + } + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiServerState.cs b/server/AyaNova/ControllerHelpers/ApiServerState.cs new file mode 100644 index 00000000..69c5d07a --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiServerState.cs @@ -0,0 +1,186 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace AyaNova.Api.ControllerHelpers +{ + + + + + /// + /// Contains the current status of the server + /// is injected everywhere for routes and others to check + /// + public class ApiServerState + { + + //private ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger(); + public enum ServerState + { + ///Unknown state, used for parsing + UNKNOWN = 0, + ///No access for anyone API completely locked down + Closed = 1, + ///Access only to API Operations routes + OpsOnly = 2, + ///Open for all users (default) + Open = 3 + } + + private ServerState _currentState = ServerState.Closed; + private ServerState _priorState = ServerState.Closed; + private string _reason = string.Empty; + private string _priorReason = string.Empty; + private bool SYSTEM_LOCK = false;//really this is a license lock but not called that + + public ApiServerState() + { + } + + + + internal void SetSystemLock(string reason) + { + //Lock down the server for license related issue + //Still allows ops routes, treats as if server was set to closed even if they change it to open + //only way to reset it is to fetch a valid license + SetState(ServerState.OpsOnly, reason); + SYSTEM_LOCK = true; + } + + + //WARNING: if in future this is used for anything other than a license then it will need to see if locked for another reason before unlocking + //recommend putting a code number in the reason then looking to see if it has the matching code + internal void ClearSystemLock() + { + SYSTEM_LOCK = false; + SetState(ServerState.Open, ""); + } + + /// + /// Set the server state + /// + /// + /// + public void SetState(ServerState newState, string reason) + { + //No changes allowed during a system lock + if (SYSTEM_LOCK) return; + + _reason = reason;//keep the reason even if the state doesn't change + if (newState == _currentState) return; + + //Here we will likely need to trigger a notification to users if the state is going to be shutting down or is shut down + _priorState = _currentState;//keep the prior state so it can be resumed easily + _priorReason=_reason;//keep the original reason + + _currentState = newState; + } + + /// + /// Get the current state of the server + /// + /// + public ServerState GetState() + { + return _currentState; + } + + /// + /// Get the current reason for the state of the server + /// + /// + public string Reason + { + get + { + if (_currentState == ServerState.Open) + { + return ""; + } + else + { + return $"Server state is: {_currentState.ToString()}, Reason: {_reason}"; + } + } + } + + + public void SetOpsOnly(string reason) + { + //No changes allowed during a system lock + if (SYSTEM_LOCK) return; + SetState(ServerState.OpsOnly, reason); + } + + + public void SetClosed(string reason) + { + //No changes allowed during a system lock + if (SYSTEM_LOCK) return; + SetState(ServerState.Closed, reason); + } + + public void SetOpen() + { + //No changes allowed during a system lock + if (SYSTEM_LOCK) return; + SetState(ServerState.Open, string.Empty); + } + + public void ResumePriorState() + { + //No changes allowed during a system lock + if (SYSTEM_LOCK) return; + SetState(_priorState, _priorReason); + } + + + public bool IsOpsOnly + { + get + { + return _currentState == ServerState.OpsOnly; + } + } + + public bool IsOpen + { + get + { + return _currentState == ServerState.Open && !SYSTEM_LOCK; + } + } + + + public bool IsClosed + { + get + { + return _currentState == ServerState.Closed || SYSTEM_LOCK; + } + } + + public bool IsOpenOrOpsOnly + { + get + { + return IsOpen || IsOpsOnly; + } + } + + public bool IsSystemLocked + { + get + { + return SYSTEM_LOCK; + } + } + + + + + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/ApiUploadProcessor.cs b/server/AyaNova/ControllerHelpers/ApiUploadProcessor.cs new file mode 100644 index 00000000..fdde5067 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/ApiUploadProcessor.cs @@ -0,0 +1,209 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using System.Collections.Generic; +using System.Linq; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Util; +using AyaNova.Biz; + + +namespace AyaNova.Api.ControllerHelpers +{ + + + //Adapted from the example found here: https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming + //See AttachmentController at bottom of class for example of form that works with this code + + /// + /// Handle processing uplod form with potentially huge files being uploaded (which means can't use simplest built in upload handler method) + /// + internal static class ApiUploadProcessor + { + + + + /// + /// Process uploaded attachment file + /// Will be treated as a temporary file for further processing into database + /// + /// + /// + internal static async Task ProcessAttachmentUpload(Microsoft.AspNetCore.Http.HttpContext httpContext) + { + return await ProcessUpload(httpContext, true); + } + + + /// + /// Process uploaded utility file (backup, import etc) + /// Anything that will be stored in the backup folder as is + /// + /// + /// + internal static async Task ProcessUtilityFileUpload(Microsoft.AspNetCore.Http.HttpContext httpContext) + { + return await ProcessUpload(httpContext, false); + } + + /// + /// handle upload + /// + /// + /// + /// list of files and form field data (if present) + private static async Task ProcessUpload(Microsoft.AspNetCore.Http.HttpContext httpContext, bool processAsAttachment) + { + + ApiUploadedFilesResult result = new ApiUploadedFilesResult(); + FormOptions _defaultFormOptions = new FormOptions(); + + // Used to accumulate all the form url encoded key value pairs in the + // request. + var formAccumulator = new KeyValueAccumulator(); + + var boundary = MultipartRequestHelper.GetBoundary( + MediaTypeHeaderValue.Parse(httpContext.Request.ContentType), + _defaultFormOptions.MultipartBoundaryLengthLimit); + var reader = new MultipartReader(boundary, httpContext.Request.Body); + + var section = await reader.ReadNextSectionAsync(); + + + while (section != null) + { + ContentDispositionHeaderValue contentDisposition; + var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition); + + if (hasContentDispositionHeader) + { + if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition)) + { + + string filePathAndName = string.Empty; + var CleanedUploadFileName = contentDisposition.FileName.Value.Replace("\"", ""); + + if (processAsAttachment) + { + //get temp file path and temp file name + filePathAndName = FileUtil.NewRandomAttachmentFileName; + } + else + { + //store directly into the backup file folder + //NOTE: all utility files are always stored as lowercase to avoid recognition issues down the road + CleanedUploadFileName = CleanedUploadFileName.ToLowerInvariant(); + filePathAndName = FileUtil.GetFullPathForUtilityFile(CleanedUploadFileName); + } + + + //save to disk + using (var stream = new FileStream(filePathAndName, FileMode.Create)) + { + section.Body.CopyTo(stream); + } + result.UploadedFiles.Add(new UploadedFileInfo() + { + InitialUploadedPathName = filePathAndName, + OriginalFileName = CleanedUploadFileName, + MimeType = section.ContentType + + }); + } + else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition)) + { + // Content-Disposition: form-data; name="key" + // + // value + + // Do not limit the key name length here because the + // multipart headers length limit is already in effect. + var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name); + var encoding = GetEncoding(section); + using (var streamReader = new StreamReader( + section.Body, + encoding, + detectEncodingFromByteOrderMarks: true, + bufferSize: 1024, + leaveOpen: true)) + { + // The value length limit is enforced by MultipartBodyLengthLimit + var value = await streamReader.ReadToEndAsync(); + if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase)) + { + value = String.Empty; + } + formAccumulator.Append(key.Value, value); + + if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit) + { + throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded."); + } + } + } + } + + // Drains any remaining section body that has not been consumed and + // reads the headers for the next section. + section = await reader.ReadNextSectionAsync(); + } + + //Get any extra form fields and return them + result.FormFieldData = formAccumulator.GetResults(); + return result; + + } + + + private static Encoding GetEncoding(MultipartSection section) + { + MediaTypeHeaderValue mediaType; + var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType); + // UTF-7 is insecure and should not be honored. UTF-8 will succeed in + // most cases. + if (!hasMediaTypeHeader || Encoding.UTF7.Equals(mediaType.Encoding)) + { + return Encoding.UTF8; + } + return mediaType.Encoding; + } + + + + + /// + /// Contains result of upload form processor + /// + public class ApiUploadedFilesResult + { + public Dictionary FormFieldData { get; set; } + public List UploadedFiles { get; set; } + + public ApiUploadedFilesResult() + { + FormFieldData = new Dictionary(); + UploadedFiles = new List(); + } + + } + + + + + }//eoc + + +}//eons diff --git a/server/AyaNova/ControllerHelpers/Authorized.cs b/server/AyaNova/ControllerHelpers/Authorized.cs new file mode 100644 index 00000000..798ce7b1 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/Authorized.cs @@ -0,0 +1,121 @@ +using EnumsNET; +using System.Collections.Generic; +using AyaNova.Biz; + + +namespace AyaNova.Api.ControllerHelpers +{ + + + internal static class Authorized + { + + /// + /// User has any ops role limited or full + /// + /// + /// + /// + internal static bool HasAnyRole(IDictionary HttpContextItems, AuthorizationRoles CheckRoles) + { + AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems); + if (currentUserRoles.HasAnyFlags(CheckRoles)) + return true; + return false; + } + + + + /// + /// READ / GENERAL ACCESS + /// + /// + /// + /// + internal static bool IsAuthorizedToRead(IDictionary HttpContextItems, AyaType objectType) + { + AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems); + + //NOTE: this assumes that if you can change you can read + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).Change)) + return true; + + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).Read)) + return true; + + return false; + + + } + + /// + /// CREATE + /// + /// + /// + /// + internal static bool IsAuthorizedToCreate(IDictionary HttpContextItems, AyaType objectType) + { + AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems); + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).Change)) + return true; + + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).EditOwn)) + return true; + + return false; + } + + + /// + /// MODIFY + /// + /// + /// + /// + /// + internal static bool IsAuthorizedToModify(IDictionary HttpContextItems, AyaType objectType, long ownerId = -1) + { + AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems); + long currentUserId = UserIdFromContext.Id(HttpContextItems); + + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).Change)) + return true; + if (ownerId != -1) + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).EditOwn) && ownerId == currentUserId) + return true; + + return false; + } + + + + + /// + /// DELETE + /// + /// + /// + /// + /// + //For now just going to treat as a modify, but for maximum flexibility keeping this as a separate method in case we change our minds in future + internal static bool IsAuthorizedToDelete(IDictionary HttpContextItems, AyaType objectType, long ownerId = 1) + { + AuthorizationRoles currentUserRoles = UserRolesFromContext.Roles(HttpContextItems); + long currentUserId = UserIdFromContext.Id(HttpContextItems); + + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).Change)) + return true; + + if (currentUserRoles.HasAnyFlags(BizRoles.GetRoleSet(objectType).EditOwn) && ownerId == currentUserId) + return true; + + return false; + } + + + + } + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/DisableFormValueModelBindingAttribute.cs b/server/AyaNova/ControllerHelpers/DisableFormValueModelBindingAttribute.cs new file mode 100644 index 00000000..71662c8c --- /dev/null +++ b/server/AyaNova/ControllerHelpers/DisableFormValueModelBindingAttribute.cs @@ -0,0 +1,50 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace AyaNova.Api.ControllerHelpers +{ + + //FROM DOCS HERE: + //https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming + //https://github.com/aspnet/Docs/tree/74a44669d5e7039e2d4d2cb3f8b0c4ed742d1124/aspnetcore/mvc/models/file-uploads/sample/FileUploadSample + /// + /// + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter + { + /// + /// + /// + /// + public void OnResourceExecuting(ResourceExecutingContext context) + { + var formValueProviderFactory = context.ValueProviderFactories + .OfType() + .FirstOrDefault(); + if (formValueProviderFactory != null) + { + context.ValueProviderFactories.Remove(formValueProviderFactory); + } + + var jqueryFormValueProviderFactory = context.ValueProviderFactories + .OfType() + .FirstOrDefault(); + if (jqueryFormValueProviderFactory != null) + { + context.ValueProviderFactories.Remove(jqueryFormValueProviderFactory); + } + } + +/// +/// +/// +/// + public void OnResourceExecuted(ResourceExecutedContext context) + { + } + } + +} \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/MultipartRequestHelper.cs b/server/AyaNova/ControllerHelpers/MultipartRequestHelper.cs new file mode 100644 index 00000000..c720a3d9 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/MultipartRequestHelper.cs @@ -0,0 +1,77 @@ +using System; +using System.IO; +using Microsoft.Net.Http.Headers; + +namespace AyaNova.Api.ControllerHelpers +{ + /// + /// + /// + public static class MultipartRequestHelper + { + // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq" + // The spec says 70 characters is a reasonable limit. + + /// + /// + /// + /// + /// + /// + public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit) + { + var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value; + if (string.IsNullOrWhiteSpace(boundary)) + { + throw new InvalidDataException("Missing content-type boundary."); + } + + if (boundary.Length > lengthLimit) + { + throw new InvalidDataException( + $"Multipart boundary length limit {lengthLimit} exceeded."); + } + + return boundary; + } + + /// + /// + /// + /// + /// + public static bool IsMultipartContentType(string contentType) + { + return !string.IsNullOrEmpty(contentType) + && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0; + } + + /// + /// + /// + /// + /// + public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition) + { + // Content-Disposition: form-data; name="key"; + return contentDisposition != null + && contentDisposition.DispositionType.Equals("form-data") + && string.IsNullOrEmpty(contentDisposition.FileName.Value) + && string.IsNullOrEmpty(contentDisposition.FileNameStar.Value); + } + + /// + /// + /// + /// + /// + public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition) + { + // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg" + return contentDisposition != null + && contentDisposition.DispositionType.Equals("form-data") + && (!string.IsNullOrEmpty(contentDisposition.FileName.Value) + || !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); + } + } +} \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/PaginationLinkBuilder.cs b/server/AyaNova/ControllerHelpers/PaginationLinkBuilder.cs new file mode 100644 index 00000000..e39674de --- /dev/null +++ b/server/AyaNova/ControllerHelpers/PaginationLinkBuilder.cs @@ -0,0 +1,89 @@ +using System; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; + +namespace AyaNova.Api.ControllerHelpers +{ + + public class PaginationLinkBuilder + { //adapted from //https://www.jerriepelser.com/blog/paging-in-aspnet-webapi-pagination-links/ + public Uri FirstPage { get; private set; } + public Uri LastPage { get; private set; } + public Uri NextPage { get; private set; } + public Uri PreviousPage { get; private set; } + public PagingOptions PagingOptions { get; } + public long TotalRecordCount { get; } + + public PaginationLinkBuilder(IUrlHelper urlHelper, string routeName, object routeValues, PagingOptions pagingOptions, long totalRecordCount) + { + PagingOptions = pagingOptions; + TotalRecordCount = totalRecordCount; + + // Determine total number of pages + var pageCount = totalRecordCount > 0 + ? (int)Math.Ceiling(totalRecordCount / (double)pagingOptions.Limit) + : 0; + + // Create page links + + FirstPage = new Uri(urlHelper.Link(routeName, new RouteValueDictionary(routeValues) + { + {"pageNo", 1}, + {"pageSize", pagingOptions.Limit} + })); + + + LastPage = new Uri(urlHelper.Link(routeName, new RouteValueDictionary(routeValues) + { + {"pageNo", pageCount}, + {"pageSize", pagingOptions.Limit} + })); + + if (pagingOptions.Offset > 1) + { + PreviousPage = new Uri(urlHelper.Link(routeName, new RouteValueDictionary(routeValues) + { + {"pageNo", pagingOptions.Offset - 1}, + {"pageSize", pagingOptions.Limit} + })); + } + + + + if (pagingOptions.Offset < pageCount) + { + NextPage = new Uri(urlHelper.Link(routeName, new RouteValueDictionary(routeValues) + { + {"pageNo", pagingOptions.Offset + 1}, + {"pageSize", pagingOptions.Limit} + })); + } + + + } + + + + /// + /// Return paging data suitable for API return + /// + /// + public Object PagingLinksObject() + { + return new + { + Count = TotalRecordCount, + Offset = PagingOptions.Offset, + Limit = PagingOptions.Limit, + First = FirstPage, + Previous = PreviousPage, + Next = NextPage, + Last = LastPage + }; + } + + + } + +} \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/PagingOptions.cs b/server/AyaNova/ControllerHelpers/PagingOptions.cs new file mode 100644 index 00000000..9470df81 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/PagingOptions.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; + +namespace AyaNova.Api.ControllerHelpers +{ + + public sealed class PagingOptions + { + public const int MaxPageSize = 100; + public const int DefaultOffset = 0; + public const int DefaultLimit = 25; + + [FromQuery] + [Range(0, int.MaxValue)] + public int? Offset { get; set; } + + [FromQuery] + [Range(1, MaxPageSize, ErrorMessage = "Limit must be greater than 0 and less than 100.")] + public int? Limit { get; set; } + + } + +} \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/UserIdFromContext.cs b/server/AyaNova/ControllerHelpers/UserIdFromContext.cs new file mode 100644 index 00000000..5563c309 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/UserIdFromContext.cs @@ -0,0 +1,21 @@ +using EnumsNET; +using System.Collections.Generic; + +namespace AyaNova.Api.ControllerHelpers +{ + + + internal static class UserIdFromContext + { + internal static long Id(IDictionary HttpContextItems) + { + + long? l = (long?)HttpContextItems["AY_USER_ID"]; + if (l==null) + return 0L; + return (long)l; + } + } + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/ControllerHelpers/UserNameFromContext.cs b/server/AyaNova/ControllerHelpers/UserNameFromContext.cs new file mode 100644 index 00000000..d18c14cd --- /dev/null +++ b/server/AyaNova/ControllerHelpers/UserNameFromContext.cs @@ -0,0 +1,20 @@ +using EnumsNET; +using System.Collections.Generic; + +namespace AyaNova.Api.ControllerHelpers +{ + + + internal static class UserNameFromContext + { + internal static string Name(IDictionary HttpContextItems) + { + string s = (string)HttpContextItems["AY_USERNAME"]; + if (string.IsNullOrWhiteSpace(s)) + return "UNKNOWN USER NAME"; + return s; + } + } + + +}//eons diff --git a/server/AyaNova/ControllerHelpers/UserRolesFromContext.cs b/server/AyaNova/ControllerHelpers/UserRolesFromContext.cs new file mode 100644 index 00000000..18f7eb03 --- /dev/null +++ b/server/AyaNova/ControllerHelpers/UserRolesFromContext.cs @@ -0,0 +1,18 @@ +using EnumsNET; +using System.Collections.Generic; +using AyaNova.Biz; + +namespace AyaNova.Api.ControllerHelpers +{ + + + internal static class UserRolesFromContext + { + internal static AuthorizationRoles Roles(IDictionary HttpContextItems) + { + return (AuthorizationRoles)HttpContextItems["AY_ROLES"]; + } + } + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/Controllers/ApiRootController.cs b/server/AyaNova/Controllers/ApiRootController.cs new file mode 100644 index 00000000..a5f4337a --- /dev/null +++ b/server/AyaNova/Controllers/ApiRootController.cs @@ -0,0 +1,76 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using System; +using AyaNova.Util; +using AyaNova.Biz; + +namespace AyaNova.Api.Controllers +{ + /// + /// Meta controller class + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/")] + public class ApiMetaController : Controller + { + + private readonly ILogger _log; + + /// + /// + /// + /// + public ApiMetaController(ILogger logger) + { + _log = logger; + } + + /// + /// AyaNova API documentation and manual + /// + /// + [HttpGet] + public ContentResult Index() + { + + var resp = $@" + + + + AyaNova server + + +
+
+

{AyaNovaVersion.FullNameAndVersion}

+ AyaNova manual

+ API explorer

+ Email AyaNova support

+

{LocaleBiz.GetDefaultLocalizedText("HelpLicense").Result}

+
{AyaNova.Core.License.LicenseInfo}
+

Schema version

+
{AySchema.currentSchema.ToString()}
+

Server time

+
{DateUtil.ServerDateTimeString(System.DateTime.UtcNow)}
+
{TimeZoneInfo.Local.Id}
+

Server logs

+
{ServerBootConfig.AYANOVA_LOG_PATH}
+
+
+ + "; + + + return new ContentResult + { + ContentType = "text/html", + StatusCode = 200, + Content = resp + }; + + } + + + + } +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/AttachmentController.cs b/server/AyaNova/Controllers/AttachmentController.cs new file mode 100644 index 00000000..f94f78db --- /dev/null +++ b/server/AyaNova/Controllers/AttachmentController.cs @@ -0,0 +1,452 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using System.Collections.Generic; +using System.Linq; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Util; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + //FROM DOCS HERE: + //https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming + //https://github.com/aspnet/Docs/tree/74a44669d5e7039e2d4d2cb3f8b0c4ed742d1124/aspnetcore/mvc/models/file-uploads/sample/FileUploadSample + + + /// + /// Attachment controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class AttachmentController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + //private static readonly FormOptions _defaultFormOptions = new FormOptions(); + + + /// + /// + /// + /// + /// + /// + public AttachmentController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + + + //TODO: Centralize this code somewhere else, it's going to be needed for backup as well + //consider the 1 hour is this legit depending on client + + /// + /// Get download token + /// A download token is good for 1 hour from issue + /// + /// Current download token for user + [HttpGet("DownloadToken")] + public async Task GetDownloadToken() + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + long lUserId = UserIdFromContext.Id(HttpContext.Items); + var u = await ct.User.FirstOrDefaultAsync(a => a.Id == lUserId); + if (u == null) + return NotFound(); + else + { + + //Generate a download token and store it with the user account + //users who are authenticated can get their token via download route + Guid g = Guid.NewGuid(); + string dlkey = Convert.ToBase64String(g.ToByteArray()); + dlkey = dlkey.Replace("=", ""); + dlkey = dlkey.Replace("+", ""); + + //get expiry date for download token + var exp = new DateTimeOffset(DateTime.Now.AddHours(1).ToUniversalTime(), TimeSpan.Zero); + + u.DlKey = dlkey; + u.DlKeyExpire = exp.DateTime; + ct.User.Update(u); + try + { + await ct.SaveChangesAsync();//triggering concurrency exception here + } + catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException) + { + log.LogInformation("Auth retry dlkey"); + }; + + return Ok(new ApiOkResponse(new { dlkey = u.DlKey, expires = u.DlKeyExpire })); + } + + } + + + + /// + /// Upload attachment file + /// + /// Required roles: Same roles as object that file is being attached to + /// + /// + /// NameValue list of filenames and attachment id's + [HttpPost] + [DisableFormValueModelBinding] + [RequestSizeLimit(10737418241)]//10737418240 = 10gb https://github.com/aspnet/Announcements/issues/267 + public async Task Upload() + { + //Adapted from the example found here: https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming + + + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + var returnList = new List(); + + + try + { + if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType)) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", $"Expected a multipart request, but got {Request.ContentType}")); + } + + var uploadFormData = await ApiUploadProcessor.ProcessAttachmentUpload(HttpContext); + + bool badRequest = false; + string AttachToObjectType = string.Empty; + string AttachToObjectId = string.Empty; + string errorMessage = string.Empty; + + if (!uploadFormData.FormFieldData.ContainsKey("AttachToObjectType") || !uploadFormData.FormFieldData.ContainsKey("AttachToObjectId")) + { + badRequest = true; + errorMessage = "AttachToObjectType and / or AttachToObjectId are missing and are required"; + } + if (!badRequest) + { + AttachToObjectType = uploadFormData.FormFieldData["AttachToObjectType"].ToString(); + AttachToObjectId = uploadFormData.FormFieldData["AttachToObjectId"].ToString(); + if (string.IsNullOrWhiteSpace(AttachToObjectType) || string.IsNullOrWhiteSpace(AttachToObjectId)) + { + badRequest = true; + errorMessage = "AttachToObjectType and / or AttachToObjectId are empty and are required"; + } + } + + + //Get type and id object from post paramters + AyaTypeId attachToObject = null; + if (!badRequest) + { + attachToObject = new AyaTypeId(AttachToObjectType, AttachToObjectId); + if (attachToObject.IsEmpty) + { + badRequest = true; + errorMessage = "AttachToObjectType and / or AttachToObjectId are not valid and are required"; + } + } + + //Is it an attachable type of object? + if (!badRequest) + { + if (!attachToObject.IsAttachable) + { + badRequest = true; + errorMessage = attachToObject.ObjectType.ToString() + " - AttachToObjectType does not support attachments"; + } + } + + + //does attach to object exist? + if (!badRequest) + { + //check if object exists + long attachToObjectOwnerId = attachToObject.OwnerId(ct); + if (attachToObjectOwnerId == -1) + { + badRequest = true; + errorMessage = "Invalid attach object"; + } + else + { + // User needs modify rights to the object type in question + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, attachToObject.ObjectType, attachToObjectOwnerId)) + { + //delete temp files + DeleteTempFileUploadDueToBadRequest(uploadFormData); + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + } + } + + + + if (badRequest) + { + //delete temp files + DeleteTempFileUploadDueToBadRequest(uploadFormData); + //return bad request + return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, errorMessage)); + } + + + //We have our files and a confirmed AyObject, ready to attach and save permanently + if (uploadFormData.UploadedFiles.Count > 0) + { + foreach (UploadedFileInfo a in uploadFormData.UploadedFiles) + { + var v = FileUtil.storeFileAttachment(a.InitialUploadedPathName, a.MimeType, a.OriginalFileName, UserIdFromContext.Id(HttpContext.Items), attachToObject, ct); + returnList.Add(new NameIdItem() + { + Name = v.DisplayFileName, + Id = v.Id + }); + } + } + } + catch (InvalidDataException ex) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", ex.Message)); + } + + //Return the list of attachment ids and filenames + return Ok(new ApiOkResponse(returnList)); + } + + /// + /// Utility to delete files that were uploaded but couldn't be stored for some reason, called by Attach route + /// + /// + private static void DeleteTempFileUploadDueToBadRequest(ApiUploadProcessor.ApiUploadedFilesResult uploadFormData) + { + if (uploadFormData.UploadedFiles.Count > 0) + { + foreach (UploadedFileInfo a in uploadFormData.UploadedFiles) + { + System.IO.File.Delete(a.InitialUploadedPathName); + } + } + } + + + /// + /// Delete Attachment + /// + /// + /// Ok + [HttpDelete("{id}")] + public async Task DeleteAttachment([FromRoute] long id) + { + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var dbObj = await ct.FileAttachment.SingleOrDefaultAsync(m => m.Id == id); + if (dbObj == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + + if (!Authorized.IsAuthorizedToDelete(HttpContext.Items, dbObj.AttachToObjectType, dbObj.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //do the delete + //this handles removing the file if there are no refs left and also the db record for the attachment + FileUtil.deleteFileAttachment(dbObj, ct); + + return NoContent(); + } + + + + + /// + /// Download a file attachment + /// + /// + /// + /// + [HttpGet("download/{id}")] + public async Task Download([FromRoute] long id, [FromQuery] string dlkey) + { + //copied from Rockfish + //https://dotnetcoretutorials.com/2017/03/12/uploading-files-asp-net-core/ + //https://stackoverflow.com/questions/45763149/asp-net-core-jwt-in-uri-query-parameter/45811270#45811270 + + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + + if (string.IsNullOrWhiteSpace(dlkey)) + { + return NotFound(); + } + + //get user by key, if not found then reject + //If user dlkeyexp has not expired then return file + var dlkeyUser = await ct.User.SingleOrDefaultAsync(m => m.DlKey == dlkey); + if (dlkeyUser == null) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.NOT_AUTHORIZED, "dlkey", "Download token not valid")); + } + + //Make sure the token provided is for the current user + long lAuthenticatedUserId = UserIdFromContext.Id(HttpContext.Items); + if (lAuthenticatedUserId != dlkeyUser.Id) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.NOT_AUTHORIZED, "dlkey", "Download token not valid")); + } + + + var utcNow = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero); + if (dlkeyUser.DlKeyExpire < utcNow.DateTime) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.NOT_AUTHORIZED, "dlkey", "Download token has expired")); + } + + //Ok, user has a valid download key and it's not expired yet so get the attachment record + var dbObj = await ct.FileAttachment.SingleOrDefaultAsync(m => m.Id == id); + if (dbObj == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + //is this allowed? + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, dbObj.AttachToObjectType)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //they are allowed, let's send the file + string mimetype = dbObj.ContentType; + var filePath = FileUtil.GetPermanentAttachmentFilePath(dbObj.StoredFileName); + if (!System.IO.File.Exists(filePath)) + { + //TODO: this should trigger some kind of notification to the ops people + //and a red light on the dashboard + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND, null, $"Physical file {dbObj.StoredFileName} not found despite attachment record, this file is missing")); + } + + return PhysicalFile(filePath, mimetype, dbObj.DisplayFileName); + + } + + //////////////////////////////////////////////////////////////////////////////////// + + + + }//eoc +}//eons + + +#region sample html form to work with this +/* + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + */ +#endregion \ No newline at end of file diff --git a/server/AyaNova/Controllers/AuthController.cs b/server/AyaNova/Controllers/AuthController.cs new file mode 100644 index 00000000..f1020d31 --- /dev/null +++ b/server/AyaNova/Controllers/AuthController.cs @@ -0,0 +1,162 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using System.Linq; +using System; +using System.Threading.Tasks; +using App.Metrics; + +//required to inject configuration in constructor +using Microsoft.Extensions.Configuration; + +namespace AyaNova.Api.Controllers +{ + /// + /// Authentication controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + public class AuthController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly IConfiguration _configuration; + private readonly ApiServerState serverState; + private readonly IMetrics metrics; + + /// + /// ctor + /// + /// + /// + /// + /// + /// + public AuthController(AyContext context, ILogger logger, IConfiguration configuration, ApiServerState apiServerState, IMetrics Metrics)//these two are injected, see startup.cs + { + ct = context; + log = logger; + _configuration = configuration; + serverState = apiServerState; + metrics = Metrics; + } + + //AUTHENTICATE CREDS + //RETURN JWT + + /// + /// Post credentials to receive a JSON web token + /// + /// + /// This route is used to authenticate to the AyaNova API. + /// Once you have a token you need to include it in all requests that require authentication like this: + /// Authorization: Bearer [TOKEN] + /// Note the space between Bearer and the token. Also, do not include the square brackets + /// + /// + /// + [HttpPost] + public async Task PostCreds([FromBody] AuthController.CredentialsParam creds) //if was a json body then //public JsonResult PostCreds([FromBody] string login, [FromBody] string password) + { + //a bit different as ops users can still login if the state is opsonly + //so the only real barrier here would be a completely closed api + if (!serverState.IsOpenOrOpsOnly) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + int nFailedAuthDelay = 10000; +#if (DEBUG) + nFailedAuthDelay = 1; +#endif + + if (string.IsNullOrWhiteSpace(creds.Login) || string.IsNullOrWhiteSpace(creds.Password)) + { + metrics.Measure.Meter.Mark(MetricsRegistry.FailedLoginMeter); + //Make a failed pw wait + await Task.Delay(nFailedAuthDelay); + return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); + } + + + //Multiple users are allowed the same password and login + //Salt will differentiate them so get all users that match login, then try to match pw + var users = await ct.User.AsNoTracking().Where(m => m.Login == creds.Login).ToListAsync(); + + foreach (User u in users) + { + string hashed = Hasher.hash(u.Salt, creds.Password); + if (hashed == u.Password) + { + //Restrict auth due to server state? + //If we're here the server state is not closed, but it might be ops only + + //If the server is ops only then this user needs to be ops or else they are not allowed in + if (serverState.IsOpsOnly && + !u.Roles.HasFlag(Biz.AuthorizationRoles.OpsAdminFull) && + !u.Roles.HasFlag(Biz.AuthorizationRoles.OpsAdminLimited)) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + + + //build the key (JWT set in startup.cs) + byte[] secretKey = System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET); + + //create a new datetime offset of now in utc time + var iat = new DateTimeOffset(DateTime.Now.ToUniversalTime(), TimeSpan.Zero);//timespan zero means zero time off utc / specifying this is a UTC datetime + var exp = new DateTimeOffset(DateTime.Now.AddDays(30).ToUniversalTime(), TimeSpan.Zero); + + var payload = new Dictionary() + { + { "iat", iat.ToUnixTimeSeconds().ToString() }, + { "exp", exp.ToUnixTimeSeconds().ToString() },//in payload exp must be in unix epoch time per standard + { "iss", "AyaNova" }, + { "id", u.Id.ToString() } + }; + + + //NOTE: probably don't need Jose.JWT as am using Microsoft jwt stuff to validate routes so it should also be able to + //issue tokens as well, but it looked cmplex and this works so unless need to remove in future keeping it. + string token = Jose.JWT.Encode(payload, secretKey, Jose.JwsAlgorithm.HS256); + + + log.LogInformation($"User number \"{u.Id}\" logged in from \"{Util.StringUtil.MaskIPAddress(HttpContext.Connection.RemoteIpAddress.ToString())}\" ok"); + metrics.Measure.Meter.Mark(MetricsRegistry.SuccessfulLoginMeter); + return Ok(new ApiOkResponse(new + { + ok = 1, + issued = iat, + expires = exp, + token = token, + id = u.Id + })); + } + } + + //No users matched, it's a failed login + //Make a failed pw wait + metrics.Measure.Meter.Mark(MetricsRegistry.FailedLoginMeter); + await Task.Delay(nFailedAuthDelay); + return StatusCode(401, new ApiErrorResponse(ApiErrorCode.AUTHENTICATION_FAILED)); + } + + //------------------------------------------------------ + + public class CredentialsParam + { + [System.ComponentModel.DataAnnotations.Required] + public string Login { get; set; } + [System.ComponentModel.DataAnnotations.Required] + public string Password { get; set; } + + } + + + }//eoc +}//eons \ No newline at end of file diff --git a/server/AyaNova/Controllers/AyaTypeController.cs b/server/AyaNova/Controllers/AyaTypeController.cs new file mode 100644 index 00000000..22dee256 --- /dev/null +++ b/server/AyaNova/Controllers/AyaTypeController.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// AyaType list controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + public class AyaTypeController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public AyaTypeController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + /// + /// Get name value list of AyaNova business object types + /// + /// Required roles: Any + /// + /// List + [HttpGet] + public ActionResult GetAyaTypes() + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + List l = new List(); + var values = Enum.GetValues(typeof(AyaType)); + foreach (AyaType t in values) + { + + string name=t.ToString(); + if(t.HasAttribute(typeof(AttachableAttribute))){ + name+=" [Attachable]"; + } + + if(t.HasAttribute(typeof(TaggableAttribute))){ + name+=" [Taggable]"; + } + + l.Add(new NameIdItem() { Name = name, Id = (long)t }); + } + + + return Ok(new ApiOkResponse(l)); + } + + + + + + + + } +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/BackupController.cs b/server/AyaNova/Controllers/BackupController.cs new file mode 100644 index 00000000..1e2217b8 --- /dev/null +++ b/server/AyaNova/Controllers/BackupController.cs @@ -0,0 +1,203 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Util; + +using System.Globalization; +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; + +using System.Collections.Generic; +using System.Linq; + + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +//************************************************************************************************************** */ +//JUNE 19th 2018 LARGE FILE UPLOAD POSSIBLY NEW INFO HERE: +//http://www.talkingdotnet.com/how-to-increase-file-upload-size-asp-net-core/ + +//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + + + + +namespace AyaNova.Api.Controllers +{ + + + //FROM DOCS HERE: + //https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming + //https://github.com/aspnet/Docs/tree/74a44669d5e7039e2d4d2cb3f8b0c4ed742d1124/aspnetcore/mvc/models/file-uploads/sample/FileUploadSample + + + /// + /// Backup and restore controller for uploading or downloading backup files + /// and triggering a restore from backup + /// + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class BackupController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + + /// + /// + /// + /// + /// + /// + public BackupController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + +/* +TODO: +A backup archive consists of a similar format to the v7 data dumper utility, json file per object in subdirectories corresponding to object type all in a zip archive + +Route to trigger restore from selected file +Route to force immediate backup +Backup code in biz objects "IBackup" interface (which in turn is probably going to implement an IExport interface as it serves dual purpose +of exporting data, or maybe that's special purpose custom objects for exporting like csv etc since there is likely a graph of data involved) + - object is exported / backed up to json +Restore code in biz objects "IRestore" interface + - object(s) imported via restore and data given to them or file or whatever (See discource project) + + */ + +//TODO: Copy the code from ImportAyaNova7Controller upload method instead of this old crap + + // /// + // /// Upload AyaNova backup files + // /// **Files of the same name will overwrite without warning** + // /// Maximum 10gb + // /// + // /// + // [HttpPost("Upload")] + // [DisableFormValueModelBinding] + // [RequestSizeLimit(10737418241)]//10737418240 = 10gb https://github.com/aspnet/Announcements/issues/267 + // public async Task Upload() + // { + // //Adapted from the example found here: https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#uploading-large-files-with-streaming + + // try + // { + // if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType)) + // { + // return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", $"Expected a multipart request, but got {Request.ContentType}")); + // } + + // // Used to accumulate all the form url encoded key value pairs in the + // // request. + // var formAccumulator = new KeyValueAccumulator(); + // //string targetFilePath = null; + + // var boundary = MultipartRequestHelper.GetBoundary( + // MediaTypeHeaderValue.Parse(Request.ContentType), + // _defaultFormOptions.MultipartBoundaryLengthLimit); + // var reader = new MultipartReader(boundary, HttpContext.Request.Body); + + // var section = await reader.ReadNextSectionAsync(); + + // while (section != null) + // { + // ContentDispositionHeaderValue contentDisposition; + // var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition); + + // if (hasContentDispositionHeader) + // { + // if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition)) + // { + // //Save file + // //as it's just a backup file there is no db involvement at all + // FileUtil.storeBackupFile(section.Body, contentDisposition.FileName.Value.Replace("\"","")); + + + // } + // else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition)) + // { + // // Content-Disposition: form-data; name="key" + // // + // // value + + // // Do not limit the key name length here because the + // // multipart headers length limit is already in effect. + // var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name); + // var encoding = GetEncoding(section); + // using (var streamReader = new StreamReader( + // section.Body, + // encoding, + // detectEncodingFromByteOrderMarks: true, + // bufferSize: 1024, + // leaveOpen: true)) + // { + // // The value length limit is enforced by MultipartBodyLengthLimit + // var value = await streamReader.ReadToEndAsync(); + // if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase)) + // { + // value = String.Empty; + // } + // formAccumulator.Append(key.Value, value); + + // if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit) + // { + // throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded."); + // } + // } + // } + // } + + // // Drains any remaining section body that has not been consumed and + // // reads the headers for the next section. + // section = await reader.ReadNextSectionAsync(); + // } + + + + + // } + // catch (InvalidDataException ex) + // { + // return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", ex.Message)); + // } + + // return Ok(); + // } + + + // private static Encoding GetEncoding(MultipartSection section) + // { + // MediaTypeHeaderValue mediaType; + // var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType); + // // UTF-7 is insecure and should not be honored. UTF-8 will succeed in + // // most cases. + // if (!hasMediaTypeHeader || Encoding.UTF7.Equals(mediaType.Encoding)) + // { + // return Encoding.UTF8; + // } + // return mediaType.Encoding; + // } + + + }//eoc +}//eons diff --git a/server/AyaNova/Controllers/ImportAyaNova7Controller.cs b/server/AyaNova/Controllers/ImportAyaNova7Controller.cs new file mode 100644 index 00000000..c220f569 --- /dev/null +++ b/server/AyaNova/Controllers/ImportAyaNova7Controller.cs @@ -0,0 +1,283 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using System.Globalization; +using System.IO; +using System.Text; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Net.Http.Headers; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Util; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + + /// + /// Import AyaNova 7 data controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class ImportAyaNova7Controller : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// + /// + /// + /// + /// + public ImportAyaNova7Controller(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + + /// + /// Upload AyaNova 7 import file + /// + /// Required roles: OpsAdminFull + /// + /// + /// NameValue list of filenames and id's + [HttpPost] + [DisableFormValueModelBinding] + [RequestSizeLimit(10737418241)]//10737418240 = 10gb https://github.com/aspnet/Announcements/issues/267 + public async Task Upload() + { + //Open or opsOnly and user is opsadminfull + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, AyaType.AyaNova7Import)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + + var returnList = new List(); + + try + { + if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType)) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", $"Expected a multipart request, but got {Request.ContentType}")); + } + + var uploadFormData = await ApiUploadProcessor.ProcessUtilityFileUpload(HttpContext); + + bool badRequest = false; + + string errorMessage = string.Empty; + + //are these the right files? + if (uploadFormData.UploadedFiles.Count > 0) + { + foreach (UploadedFileInfo a in uploadFormData.UploadedFiles) + { + //should look like this: ayanova.data.dump.2018-04-2--12-30-57.zip + string lwr = a.OriginalFileName.ToLowerInvariant(); + if (!(lwr.StartsWith("ayanova.data.dump") && lwr.EndsWith(".zip"))) + { + badRequest = true; + errorMessage = $"File uploaded \"{lwr}\" does not appear to be an AyaNova 7 data dump file. The name should start with \"ayanova.data.dump\" have a date in the middle and end with \".zip\". Upload process is terminated without saving."; + } + } + } + + + if (badRequest) + { + //delete temp files + if (uploadFormData.UploadedFiles.Count > 0) + { + foreach (UploadedFileInfo a in uploadFormData.UploadedFiles) + { + System.IO.File.Delete(a.InitialUploadedPathName); + } + } + //return bad request + return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, errorMessage)); + } + + + //We have our files and a confirmed AyObject, ready to attach and save permanently + if (uploadFormData.UploadedFiles.Count > 0) + { + foreach (UploadedFileInfo a in uploadFormData.UploadedFiles) + { + returnList.Add(a.OriginalFileName); + } + } + } + catch (InvalidDataException ex) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "FileUploadAttempt", ex.Message)); + } + + //Return the list of attachment ids and filenames + return Ok(new ApiOkResponse(returnList)); + } + + + /// + /// Delete import file + /// + /// Required roles: OpsAdminFull + /// + /// + /// Ok + [HttpDelete("{filename}")] + public ActionResult Delete([FromRoute] string filename) + { + //Open or opsOnly and user is opsadminfull + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + + if (!Authorized.IsAuthorizedToDelete(HttpContext.Items, AyaType.AyaNova7Import)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //do the delete + //this handles removing the file if there are no efs left and also the db record for the attachment + FileUtil.DeleteUtilityFile(filename); + + return NoContent(); + } + + + /// + /// Get AyaNova 7 data dump uploaded files list + /// + /// Required roles: OpsAdminFull + /// + /// This list cannot be filtered or queried + /// + /// + /// List of uploaded data dump files + [HttpGet] + public ActionResult List() + { + //Open or opsOnly and user is opsadminfull + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.AyaNova7Import)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //dump file name example: ayanova.data.dump.XXX.zip + List l = FileUtil.UtilityFileList("ayanova.data.dump.*.zip"); + return Ok(new ApiOkResponse(l)); + } + + + /// + /// Start import of previously uploaded import file + /// + /// Required roles: OpsAdminFull + /// + /// + /// + /// Ok + [HttpPost("startImport/{filename}")] + public ActionResult StartImport([FromRoute] string filename) + { + //Open or opsOnly and user is opsadminfull + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + +//UPDATE: I think it should be ok so commenting this out for now pending something coming up in testing +// //TODO: I decided not to allow trial to import v7 data. +// //This was a snap decision, I didn't think about it much other than +// //I'm concerned right now as of April 17 2018 during development that +// //a trial user will import their old AyaNova data and then ... well somehow continue to use it I guess, +// //maybe it's a non-issue as a trial will only work so long anyway +// #if (!DEBUG) +// if (AyaNova.Core.License.LicenseIsTrial) +// { +// return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, "Current license is a trial license key. Only a licensed database can be used with import.")); +// } +// #endif + + //Create, in that they are creating new data in AyaNova + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, AyaType.AyaNova7Import)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //does the file even exist? + if (!FileUtil.UtilityFileExists(filename)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND, "filename", "File not found, ensure the name via the GET route endpoint list of previously uploaded import files")); + } + + + //Create the job here + dynamic jobInfo = new JObject(); + jobInfo.ImportFileName = filename; + + OpsJob j = new OpsJob(); + j.Name = $"Import AyaNova7 data (import file \"{filename}\""; + j.JobType = JobType.ImportV7Data; + j.OwnerId = UserIdFromContext.Id(HttpContext.Items); + j.JobInfo = jobInfo.ToString(); + JobsBiz.AddJob(j, ct); + return Accepted(new { JobId = j.GId });//202 accepted + } + + //////////////////////////////////////////////////////////////////////////////////// + + + + }//eoc +}//eons + diff --git a/server/AyaNova/Controllers/JobOperationsController.cs b/server/AyaNova/Controllers/JobOperationsController.cs new file mode 100644 index 00000000..61546829 --- /dev/null +++ b/server/AyaNova/Controllers/JobOperationsController.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// Tag controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class JobOperationsController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public JobOperationsController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + + + + /// + /// Get Operations jobs list + /// + /// Required roles: OpsAdminFull, OpsAdminLimited, BizAdminFull, BizAdminLimited + /// + /// This list cannot be filtered or queried as there are typically not many jobs + /// + /// + /// List of operations jobs + [HttpGet] + public async Task List() + { + //Open or opsOnly and user is opsadminfull or opsadminlimited + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.JobOperations)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + JobOperationsBiz biz = new JobOperationsBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + List l = await biz.GetJobListAsync(); + return Ok(new ApiOkResponse(l)); + } + + + + + /// + /// Get Operations log for a job + /// + /// Required roles: OpsAdminFull, OpsAdminLimited, BizAdminFull, BizAdminLimited + /// + /// This list cannot be filtered or queried as there are typically not many jobs + /// + /// + /// + /// A tag + [HttpGet("logs/{gid}")] + public async Task GetLogs([FromRoute] Guid gid) + { + //Open or opsOnly and user is opsadminfull or opsadminlimited + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.JobOperations)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + JobOperationsBiz biz = new JobOperationsBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + List l = await biz.GetJobLogListAsync(gid); + return Ok(new ApiOkResponse(l)); + } + + + + + + //------------ + + + + + + + }//eoc +}//eons \ No newline at end of file diff --git a/server/AyaNova/Controllers/LicenseController.cs b/server/AyaNova/Controllers/LicenseController.cs new file mode 100644 index 00000000..eb0b1e57 --- /dev/null +++ b/server/AyaNova/Controllers/LicenseController.cs @@ -0,0 +1,197 @@ +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using System.ComponentModel.DataAnnotations; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// License route + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class LicenseController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public LicenseController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + + + /// + /// Get License info + /// + /// Required roles: + /// AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminFull | + /// AuthorizationRoles.BizAdminLimited | AuthorizationRoles.OpsAdminLimited + /// + /// Information about the currently installed license in AyaNova + [HttpGet()] + public ActionResult GetLicenseInfo() + { + //Open or opsOnly and user is opsadminfull or opsadminlimited + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.License)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + var ret = AyaNova.Core.License.LicenseInfoAsJson; + + return Ok(new ApiOkResponse(ret)); + } + + + + + /// + /// Fetch license + /// + /// Posting to this route causes AyaNova to attempt to refresh it's license + /// from the AyaNova license server + /// + /// Required roles: + /// AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminFull + /// + /// On success returns information about the currently installed license in AyaNova + [HttpPost] + public ActionResult FetchLicense() + { + //Open or opsOnly and user is opsadminfull + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, AyaType.License)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + try + { + AyaNova.Core.License.Fetch(serverState, ct, log); + } + catch (Exception ex) + { + Exception rootex = ex; + while (rootex.InnerException != null) + { + rootex = rootex.InnerException; + } + + + if (rootex.Message.Contains("E1020")) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, "LICENSE_KEY", rootex.Message)); + } + else + { + throw ex; + } + + } + var ret = AyaNova.Core.License.LicenseInfoAsJson; + + return Ok(new ApiOkResponse(ret)); + } + + + + /// + /// Request trial license + /// + /// Posting to this route causes AyaNova to request a trial license key from the AyaNova license server + /// Database must be empty and unlicensed or trial license + /// + /// Required roles: + /// [OpsFull, BizAdminFull] + /// + /// + /// + /// HTTP 204 No Content result code on success or fail code with explanation + [HttpPost("trial")] + public ActionResult RequestTrial([FromBody] dtoTrialRequestData requestData) + { + //Open or opsOnly and user is opsadminfull + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, AyaType.License)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + if (!AyaNova.Util.DbUtil.DBIsEmpty(ct, log)) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, "Only an empty AyaNova database can request a trial key. Erase the database to proceed with a new trial.")); + } + + if (!AyaNova.Core.License.ActiveKey.IsEmpty && !AyaNova.Core.License.ActiveKey.TrialLicense) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, "There is an active registered license. Only an unlicensed or trial license database can request a trial key.")); + } + + //Send the request to RockFish here (or at least start the job to do it in which case return Accepted instead of no content and update comment above) + var ret = Core.License.RequestTrial(requestData.EmailAddress, requestData.RegisteredTo, log); + + return Ok(new ApiOkResponse(ret)); + } + + //------------------------------------------------------ + + public class dtoTrialRequestData + { + [System.ComponentModel.DataAnnotations.Required] + public string RegisteredTo { get; set; } + [System.ComponentModel.DataAnnotations.Required, System.ComponentModel.DataAnnotations.EmailAddress] + public string EmailAddress { get; set; } + + } + + + + + }//eoc +}//eons \ No newline at end of file diff --git a/server/AyaNova/Controllers/LocaleController.cs b/server/AyaNova/Controllers/LocaleController.cs new file mode 100644 index 00000000..f99c1c4c --- /dev/null +++ b/server/AyaNova/Controllers/LocaleController.cs @@ -0,0 +1,384 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + //DOCUMENTATING THE API + //https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/xmldoc/recommended-tags-for-documentation-comments + //https://github.com/domaindrivendev/Swashbuckle.AspNetCore#include-descriptions-from-xml-comments + + /// + /// Localized text controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class LocaleController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public LocaleController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + + + /// + /// Get Locale all values + /// + /// Required roles: Any + /// + /// + /// A single Locale and it's values + [HttpGet("{id}")] + public async Task GetLocale([FromRoute] long id) + { + if (serverState.IsClosed) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + LocaleBiz biz = new LocaleBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + var o = await biz.GetAsync(id); + + if (o == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + return Ok(new ApiOkResponse(o)); + } + + + + /// + /// Get Locale pick list + /// Required roles: Any + /// + /// + /// Picklist in alphabetical order of all locales + [HttpGet("PickList")] + public async Task LocalePickList() + { + if (serverState.IsClosed) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + //Instantiate the business object handler + LocaleBiz biz = new LocaleBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + var l = await biz.GetPickListAsync(); + return Ok(new ApiOkResponse(l)); + } + + + /// + /// Get subset of locale values + /// Required roles: Any + /// + /// + /// LocaleSubsetParam object defining the locale Id and a list of keys required + /// A key value array of localized text values + [HttpPost("SubSet")] + public async Task SubSet([FromBody] LocaleSubsetParam inObj) + { + if (serverState.IsClosed) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + //Instantiate the business object handler + LocaleBiz biz = new LocaleBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + var l = await biz.GetSubset(inObj); + return Ok(new ApiOkResponse(l)); + } + + + /// + /// Duplicates an existing locale with a new name + /// + /// Required roles: OpsAdminFull | BizAdminFull + /// + /// + /// NameIdItem object containing source locale Id and new name + /// Error response or newly created locale + [HttpPost("Duplicate")] + public async Task Duplicate([FromBody] NameIdItem inObj) + { + if (serverState.IsClosed) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + //Instantiate the business object handler + LocaleBiz biz = new LocaleBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + var o = await biz.DuplicateAsync(inObj); + + if (o == null) + { + //error return + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + else + { + //save and success return + await ct.SaveChangesAsync(); + return CreatedAtAction("GetLocale", new { id = o.Id }, new ApiCreatedResponse(o)); + } + } + + + + /// + /// Put (UpdateLocaleItemDisplayText) + /// + /// Required roles: OpsAdminFull | BizAdminFull + /// + /// Update a single key with new display text + /// + /// + /// NewText/Id/Concurrency token object. NewText is new display text, Id is LocaleItem Id, concurrency token is required + /// + [HttpPut("UpdateLocaleItemDisplayText")] + public async Task PutLocalItemDisplyaText([FromBody] NewTextIdConcurrencyTokenItem inObj) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var oFromDb = await ct.LocaleItem.SingleOrDefaultAsync(m => m.Id == inObj.Id); + + if (oFromDb == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + //Now fetch locale for rights and to ensure not stock + var oDbParent = await ct.Locale.SingleOrDefaultAsync(x => x.Id == oFromDb.LocaleId); + if (oDbParent == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.Locale, oDbParent.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Instantiate the business object handler + LocaleBiz biz = new LocaleBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + if (!biz.PutLocaleItemDisplayText(oFromDb, inObj, oDbParent)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!biz.LocaleItemExists(inObj.Id)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + else + { + //exists but was changed by another user + //I considered returning new and old record, but where would it end? + //Better to let the client decide what to do than to send extra data that is not required + return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); + } + } + + return Ok(new ApiOkResponse(new { ConcurrencyToken = oFromDb.ConcurrencyToken })); + } + + /// + /// Put (UpdateLocaleName) + /// + /// Required roles: OpsAdminFull | BizAdminFull + /// + /// Update a locale to change the name (non-stock locales only) + /// + /// + /// NewText/Id/Concurrency token object. NewText is new locale name, Id is Locale Id, concurrency token is required + /// + [HttpPut("UpdateLocaleName")] + public async Task PutLocaleName([FromBody] NewTextIdConcurrencyTokenItem inObj) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var oFromDb = await ct.Locale.SingleOrDefaultAsync(m => m.Id == inObj.Id); + + if (oFromDb == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.Locale, oFromDb.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Instantiate the business object handler + LocaleBiz biz = new LocaleBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + if (!biz.PutLocaleName(oFromDb, inObj)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!biz.LocaleExists(inObj.Id)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + else + { + //exists but was changed by another user + //I considered returning new and old record, but where would it end? + //Better to let the client decide what to do than to send extra data that is not required + return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); + } + } + + return Ok(new ApiOkResponse(new { ConcurrencyToken = oFromDb.ConcurrencyToken })); + } + + + /// + /// Delete Locale + /// + /// Required roles: + /// BizAdminFull, InventoryFull + /// + /// + /// + /// Ok + [HttpDelete("{id}")] + public async Task DeleteLocale([FromRoute] long id) + { + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + + //Fetch locale and it's children + //(fetch here so can return proper REST responses on failing basic validity) + var dbObj = ct.Locale.Include(x => x.LocaleItems).SingleOrDefault(m => m.Id == id); + if (dbObj == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToDelete(HttpContext.Items, AyaType.Locale, dbObj.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + + //Instantiate the business object handler + LocaleBiz biz = new LocaleBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + if (!biz.Delete(dbObj)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + await ct.SaveChangesAsync(); + + //Delete children / attached objects + // biz.DeleteChildren(dbObj); + + return NoContent(); + } + + + + + // private bool LocaleExists(long id) + // { + // return ct.Locale.Any(e => e.Id == id); + // } + + + + + //------------ + + public class LocaleSubsetParam + { + [System.ComponentModel.DataAnnotations.Required] + public long LocaleId { get; set; } + [System.ComponentModel.DataAnnotations.Required] + public List Keys { get; set; } + + } + + } +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/LogFilesController.cs b/server/AyaNova/Controllers/LogFilesController.cs new file mode 100644 index 00000000..4af5099b --- /dev/null +++ b/server/AyaNova/Controllers/LogFilesController.cs @@ -0,0 +1,177 @@ +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// Log files controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + //[Produces("application/json")] + [Authorize] + public class LogFilesController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public LogFilesController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + + + /// + /// Get server log + /// + /// Required roles: + /// OpsAdminFull | OpsAdminLimited + /// + /// + /// A single log file in plain text + [HttpGet("{logname}")] + public ActionResult GetLog([FromRoute] string logname) + { + //Open or opsOnly and user is opsadminfull or opsadminlimited + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.LogFile)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //stream the file contents into a json object and return + + //build the full path from the log file name and defined path + var logFilePath = System.IO.Path.Combine(ServerBootConfig.AYANOVA_LOG_PATH, logname); + //does file exist? + if (!System.IO.File.Exists(logFilePath)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + // //read it and stream it back in a json object + + + // Newtonsoft.Json.Linq.JObject o = Newtonsoft.Json.Linq.JObject.FromObject(new + // { + // log = new + // { + // name = logname, + // log = System.IO.File.ReadAllText(logFilePath) + // } + // }); + + // return Ok(new ApiOkResponse(o)); + + return Content(System.IO.File.ReadAllText(logFilePath)); + + } + + + + /// + /// Get list of operations logs + /// + /// Required roles: + /// OpsAdminFull | OpsAdminLimited + /// + /// + /// + [HttpGet()] + public ActionResult ListLogs() + { + //Open or opsOnly and user is opsadminfull or opsadminlimited + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.LogFile)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + + //Iterate all log files and build return + var files = System.IO.Directory.GetFiles(ServerBootConfig.AYANOVA_LOG_PATH, "log-ayanova*.txt"); + + Newtonsoft.Json.Linq.JObject o = Newtonsoft.Json.Linq.JObject.FromObject(new + { + logs = + from f in files + orderby f + select new + { + logName = System.IO.Path.GetFileName(f) + } + }); + + // Newtonsoft.Json.Linq.JObject o = Newtonsoft.Json.Linq.JObject.FromObject(new + // { + // logs = new + // { + // licensedTo = ActiveKey.RegisteredTo, + // registeredEmail = ActiveKey.FetchEmail, + // trial = ActiveKey.Trial, + // keySerial = ActiveKey.Id, + // keySource = ActiveKey.Source, + // created = ActiveKey.Created.ToString(), + // features = + // from f in files + // orderby f + // select new + // { + // logName = f + // } + // } + // }); + + + + return Ok(new ApiOkResponse(o)); + } + + + + + //------------ + + + } +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/MetricsController.cs b/server/AyaNova/Controllers/MetricsController.cs new file mode 100644 index 00000000..2da0f673 --- /dev/null +++ b/server/AyaNova/Controllers/MetricsController.cs @@ -0,0 +1,137 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; +using App.Metrics; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// Log files controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Authorize] + public class MetricsController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + private readonly IMetrics metrics; + + /// + /// ctor + /// + /// + /// + /// + /// + public MetricsController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState, IMetrics Metrics) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + metrics = Metrics; + } + + + /// + /// Get metrics as text document + /// + /// Required roles: + /// OpsAdminFull | OpsAdminLimited + /// + /// Snapshot of metrics + [HttpGet("TextSnapShot")] + public async Task GetMetrics() + { + //Open or opsOnly and user is opsadminfull or opsadminlimited + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Metrics)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + string sResult = await GetTheMetrics("plain"); + return Content(sResult); + } + + + /// + /// Get metrics as json object + /// + /// Required roles: + /// OpsAdminFull | OpsAdminLimited + /// + /// Snapshot of metrics + [HttpGet("JsonSnapShot")] + public async Task GetJsonMetrics() + { + //Open or opsOnly and user is opsadminfull or opsadminlimited + if (!serverState.IsOpenOrOpsOnly || (serverState.IsOpsOnly && !Authorized.HasAnyRole(HttpContext.Items, AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited))) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Metrics)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + string sResult = await GetTheMetrics("json"); + + //return Ok(new ApiOkResponse(new { metrics = sResult })); + // /THIS IS NOT RETURNING VALID PARSEABLE JSON, FIX IT + //IDEAS: + //try parsing the result first then return it + // + JObject json = JObject.Parse(sResult); + return Ok(new ApiOkResponse(json)); + } + + /// + /// Get the metrics snapshot + /// + /// Either "json" for json format or "plain" for plaintext format + /// + private async Task GetTheMetrics(string format) + { + var snapshot = metrics.Snapshot.Get(); + + var formatters = ((IMetricsRoot)metrics).OutputMetricsFormatters; + string sResult = $"ERROR GETTING METRICS IN {format} FORMAT"; + + foreach (var formatter in formatters) + { + if (formatter.MediaType.Format == format) + { + using (var stream = new MemoryStream()) + { + await formatter.WriteAsync(stream, snapshot); + sResult = System.Text.Encoding.UTF8.GetString(stream.ToArray()); + } + } + } + + return sResult; + } + + + //------------ + + + } +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/ServerStateController.cs b/server/AyaNova/Controllers/ServerStateController.cs new file mode 100644 index 00000000..895a5414 --- /dev/null +++ b/server/AyaNova/Controllers/ServerStateController.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using System.ComponentModel.DataAnnotations; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// Server state controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + public class ServerStateController : Controller + { + private readonly ILogger log; + private readonly ApiServerState serverState; + + /// + /// ctor + /// + /// + /// + public ServerStateController(ILogger logger, ApiServerState apiServerState) + { + log = logger; + serverState = apiServerState; + } + + + /// + /// Get server state + /// + /// Required roles: + /// [NONE / authentication not required] + /// + /// Current server state (Closed, OpsOnly, Open) + [HttpGet] + public ActionResult Get() + { + return Ok(new ApiOkResponse(new ServerStateModel() { ServerState = serverState.GetState().ToString(), Reason = serverState.Reason })); + } + + + /// + /// Set server state + /// + /// Required roles: + /// [OpsFull, BizAdminFull] + /// + /// Valid parameters: + /// One of "Closed", "OpsOnly" or "Open" + /// + /// + /// {"NewState":"Closed"} + /// NoContent 204 + [HttpPost] + [Authorize] + public ActionResult PostServerState([FromBody] ServerStateModel state) + { + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.ServerState)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (serverState.IsSystemLocked)//no state change allowed when system locked, must correct the problem first + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + ApiServerState.ServerState desiredState; + + if (!Enum.TryParse(state.ServerState, true, out desiredState)) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.VALIDATION_INVALID_VALUE, null, "Invalid state - must be one of \"Closing\", \"Closed\", \"OpsOnly\" or \"Open\"")); + } + + log.LogInformation($"ServerState change request by user {UserNameFromContext.Name(HttpContext.Items)} from current state of \"{serverState.GetState().ToString()}\" to \"{desiredState.ToString()}\""); + + serverState.SetState(desiredState, state.Reason); + + return NoContent(); + } + + + /// + /// Parameter object + /// + public class ServerStateModel + { + /// + /// One of "Closed", "OpsOnly" or "Open" + /// + /// + [Required] + public string ServerState { get; set; } + + /// + /// Reason for server state + /// + /// + public string Reason { get; set; } + } + + //------------ + + + } +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/TagController.cs b/server/AyaNova/Controllers/TagController.cs new file mode 100644 index 00000000..2acb82de --- /dev/null +++ b/server/AyaNova/Controllers/TagController.cs @@ -0,0 +1,366 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// Tag controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class TagController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public TagController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + /// + /// Get Tag + /// + /// Required roles: + /// AnyOne + /// + /// + /// A tag + [HttpGet("{id}")] + public async Task GetTag([FromRoute] long id) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Tag)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + TagBiz biz = new TagBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + var o = await biz.GetAsync(id); + + if (o == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + return Ok(new ApiOkResponse(o)); + } + + + /// + /// Get Tag pick list + /// + /// Required roles: AnyRole + /// + /// This endpoint queries the Name property of tags for + /// items that **START WITH** the characters submitted in the + /// "q" parameter + /// + /// Unlike most other picklists, wildcard characters if found in the query will be escaped and be considered part of the search string + /// Query is case insensitive as all tags are lowercase + /// + /// Empty queries will return all tags + /// + /// + /// Paged id/name collection of tags with paging data + [HttpGet("PickList", Name = nameof(PickList))] + public async Task PickList([FromQuery] string q, [FromQuery] PagingOptions pagingOptions) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Tag))//Note: anyone can read a tag, but that might change in future so keeping this code in + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + TagBiz biz = new TagBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + ApiPagedResponse pr = await biz.GetPickListAsync(Url, nameof(PickList), pagingOptions, q); + return Ok(new ApiOkWithPagingResponse(pr)); + } + + + + /// + /// Post TAG + /// + /// Required roles: + /// BizAdminFull, DispatchFull, InventoryFull, TechFull, AccountingFull + /// + /// String name of tag + /// object + [HttpPost] + public async Task PostTag([FromBody] NameItem inObj) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + //If a user has change roles, or editOwnRoles then they can create, true is passed for isOwner since they are creating so by definition the owner + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, AyaType.Tag)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + TagBiz biz = new TagBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + //Create and validate + Tag o = await biz.CreateAsync(inObj.Name); + + if (o == null) + { + //error return + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + else + { + //save and success return + await ct.SaveChangesAsync(); + return CreatedAtAction("GetTag", new { id = o.Id }, new ApiCreatedResponse(o)); + } + } + + + + /// + /// Put (update) Tag + /// + /// Required roles: + /// BizAdminFull, DispatchFull, InventoryFull, TechFull, AccountingFull + /// + /// + /// + /// + /// + [HttpPut("{id}")] + public async Task PutTag([FromRoute] long id, [FromBody] Tag oIn) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var oFromDb = await ct.Tag.SingleOrDefaultAsync(m => m.Id == id); + + if (oFromDb == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.Tag, oFromDb.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Instantiate the business object handler + TagBiz biz = new TagBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + if (!biz.Put(oFromDb, oIn)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!TagExists(id)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + else + { + //exists but was changed by another user + //I considered returning new and old record, but where would it end? + //Better to let the client decide what to do than to send extra data that is not required + return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); + } + } + + return Ok(new ApiOkResponse(new { ConcurrencyToken = oFromDb.ConcurrencyToken })); + } + + + /// + /// Patch (update) Tag + /// Required roles: BizAdminFull, DispatchFull, InventoryFull, TechFull, AccountingFull + /// + /// + /// + /// + /// + [HttpPatch("{id}/{concurrencyToken}")] + public async Task PatchTag([FromRoute] long id, [FromRoute] uint concurrencyToken, [FromBody]JsonPatchDocument objectPatch) + { + //https://dotnetcoretutorials.com/2017/11/29/json-patch-asp-net-core/ + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + TagBiz biz = new TagBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + + var oFromDb = await ct.Tag.SingleOrDefaultAsync(m => m.Id == id); + + if (oFromDb == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.Tag, oFromDb.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //patch and validate + if (!biz.Patch(oFromDb, objectPatch, concurrencyToken)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!TagExists(id)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + else + { + return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); + } + } + + return Ok(new ApiOkResponse(new { ConcurrencyToken = oFromDb.ConcurrencyToken })); + } + + + + /// + /// Delete Tag + /// Required roles: BizAdminFull, DispatchFull, InventoryFull, TechFull, AccountingFull + /// + /// + /// Ok + [HttpDelete("{id}")] + public async Task DeleteTag([FromRoute] long id) + { + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var dbObj = await ct.Tag.SingleOrDefaultAsync(m => m.Id == id); + if (dbObj == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToDelete(HttpContext.Items, AyaType.Tag, dbObj.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Instantiate the business object handler + TagBiz biz = new TagBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + if (!biz.Delete(dbObj)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + await ct.SaveChangesAsync(); + + return NoContent(); + } + + + + private bool TagExists(long id) + { + return ct.Tag.Any(e => e.Id == id); + } + + + + //------------ + + + } +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/TagMapController.cs b/server/AyaNova/Controllers/TagMapController.cs new file mode 100644 index 00000000..ad3be4e7 --- /dev/null +++ b/server/AyaNova/Controllers/TagMapController.cs @@ -0,0 +1,258 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + + /// + /// TagMap controller + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class TagMapController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public TagMapController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + /// + /// Get TagMap object + /// + /// Required roles: Same roles as tagged object + /// + /// + /// A TagMap + [HttpGet("{id}")] + public async Task GetTagMap([FromRoute] long id) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.TagMap)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + TagMapBiz biz = new TagMapBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + var o = await biz.GetAsync(id); + + if (o == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + //Check rights to parent tagged object + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, o.TagToObjectType)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + + return Ok(new ApiOkResponse(o)); + } + + + /// + /// Post TagMap - Map a tag to an object / Id + /// + /// Required roles: Same roles as tagged object + /// + /// TagMapInfo + /// object + [HttpPost] + public async Task PostTagMap([FromBody] TagMapInfo inObj) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + //If a user has change roles, or editOwnRoles then they can create, true is passed for isOwner since they are creating so by definition the owner + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, AyaType.TagMap)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Rights to parent taggable object? + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, inObj.TagToObjectType)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + TagMapBiz biz = new TagMapBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + //Create and validate + TagMap o = await biz.CreateAsync(inObj); + + if (o == null) + { + //error return + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + else + { + //save and success return + await ct.SaveChangesAsync(); + return CreatedAtAction("GetTagMap", new { id = o.Id }, new ApiCreatedResponse(o)); + } + } + + + /// + /// Delete TagMap + /// Required roles: Same roles as tagged object + /// + /// + /// Ok + [HttpDelete("{id}")] + public async Task DeleteTagMap([FromRoute] long id) + { + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var dbObj = await ct.TagMap.SingleOrDefaultAsync(m => m.Id == id); + if (dbObj == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToDelete(HttpContext.Items, AyaType.TagMap, dbObj.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Rights to parent tagged object? + if (!Authorized.IsAuthorizedToDelete(HttpContext.Items, dbObj.TagToObjectType, dbObj.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Instantiate the business object handler + TagMapBiz biz = new TagMapBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + if (!biz.Delete(dbObj)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + await ct.SaveChangesAsync(); + + return NoContent(); + } + + + + + + + /// + /// Get Tag pick list + /// + /// Required roles: Follows parent (tagged object) roles + /// + /// + /// Name / Id collection of tags on object + [HttpGet("TagsOnObject")] + public async Task TagsOnObjectList([FromBody] TypeAndIdInfo inObj) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Tag))//Note: anyone can read a tag, but that might change in future so keeping this code in + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + + //Check rights to parent tagged object + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, inObj.ObjectType)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + + //Instantiate the business object handler + TagMapBiz biz = new TagMapBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + + var l = await biz.GetTagsOnObjectListAsync(new AyaTypeId(inObj.ObjectType, inObj.ObjectId)); + return Ok(new ApiOkResponse(l)); + } + + + + + + + private bool TagMapExists(long id) + { + return ct.TagMap.Any(e => e.Id == id); + } + + + + //------------ + + + + + + }//eoc +} \ No newline at end of file diff --git a/server/AyaNova/Controllers/TrialController.cs b/server/AyaNova/Controllers/TrialController.cs new file mode 100644 index 00000000..09b040e1 --- /dev/null +++ b/server/AyaNova/Controllers/TrialController.cs @@ -0,0 +1,124 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using Newtonsoft.Json.Linq; + +namespace AyaNova.Api.Controllers +{ + + /// + ///Test controller class used during development + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class TrialController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + /// + /// ctor + /// + /// + /// + /// + public TrialController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + /// + /// Seed a trial database with sample data. + /// + /// You can control the size and scope of the seeded data with the passed in size value + /// "Small" - a small one man shop dataset + /// "Medium" - Local service company with multiple employees and departments dataset + /// "Large" - Large corporate multi regional dataset + /// + /// Valid values are "Small", "Medium", "Large" + /// + [HttpPost("seed/{size}")] + public ActionResult SeedTrialDatabase([FromRoute] string size) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + if (!AyaNova.Core.License.ActiveKey.TrialLicense) + { + return BadRequest(new ApiErrorResponse(ApiErrorCode.INVALID_OPERATION, null, "Current license is not a trial license key. Only a trial can be seeded.")); + } + + Seeder.SeedLevel seedLevel = Seeder.SeedLevel.SmallOneManShopTrialDataSet; + switch (size.ToLowerInvariant()) + { + case "small": + seedLevel = Seeder.SeedLevel.SmallOneManShopTrialDataSet; + break; + case "medium": + seedLevel = Seeder.SeedLevel.MediumLocalServiceCompanyTrialDataSet; + break; + case "large": + seedLevel = Seeder.SeedLevel.LargeCorporateMultiRegionalTrialDataSet; + break; + default: + return BadRequest(new ApiErrorResponse(ApiErrorCode.NOT_FOUND, "size", "Valid values are \"small\", \"medium\", \"large\"")); + } + + + //Create the job here + + JObject o = JObject.FromObject(new + { + seedLevel = seedLevel + // channel = new + // { + // title = "Star Wars", + // link = "http://www.starwars.com", + // description = "Star Wars blog.", + // item = + // from p in posts + // orderby p.Title + // select new + // { + // title = p.Title, + // description = p.Description, + // link = p.Link, + // category = p.Categories + // } + // } + }); + + OpsJob j = new OpsJob(); + j.Name = $"Seed test data (size={size})"; + j.JobType = JobType.SeedTestData; + j.Exclusive=true;//don't run other jobs, this will erase the db + j.JobInfo = o.ToString(); + JobsBiz.AddJob(j, ct); + return Accepted(new { JobId = j.GId });//202 accepted + + } + + + + //------------ + + + }//eoc +}//eons \ No newline at end of file diff --git a/server/AyaNova/Controllers/WidgetController.cs b/server/AyaNova/Controllers/WidgetController.cs new file mode 100644 index 00000000..fa74ee8d --- /dev/null +++ b/server/AyaNova/Controllers/WidgetController.cs @@ -0,0 +1,482 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + + +namespace AyaNova.Api.Controllers +{ + //DOCUMENTATING THE API + //https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/xmldoc/recommended-tags-for-documentation-comments + //https://github.com/domaindrivendev/Swashbuckle.AspNetCore#include-descriptions-from-xml-comments + + /// + /// Sample controller class used during development for testing purposes + /// + [ApiVersion("8.0")] + [Route("api/v{version:apiVersion}/[controller]")] + [Produces("application/json")] + [Authorize] + public class WidgetController : Controller + { + private readonly AyContext ct; + private readonly ILogger log; + private readonly ApiServerState serverState; + + + /// + /// ctor + /// + /// + /// + /// + public WidgetController(AyContext dbcontext, ILogger logger, ApiServerState apiServerState) + { + ct = dbcontext; + log = logger; + serverState = apiServerState; + } + + + + + /// + /// Get widget + /// + /// Required roles: + /// BizAdminFull, InventoryFull, BizAdminLimited, InventoryLimited, TechFull, TechLimited, Accounting + /// + /// + /// A single widget + [HttpGet("{id}")] + public async Task GetWidget([FromRoute] long id) + { + if (serverState.IsClosed) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Widget)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + WidgetBiz biz = new WidgetBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + var o = await biz.GetAsync(id); + + if (o == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + return Ok(new ApiOkResponse(o)); + } + + + + /// + /// Get paged list of widgets + /// + /// Required roles: + /// BizAdminFull, InventoryFull, BizAdminLimited, InventoryLimited, TechFull, TechLimited, Accounting + /// + /// + /// Paged collection of widgets with paging data + [HttpGet("List", Name = nameof(List))]//We MUST have a "Name" defined or we can't get the link for the pagination, non paged urls don't need a name + public async Task List([FromQuery] PagingOptions pagingOptions) + { + + if (serverState.IsClosed) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Widget)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + WidgetBiz biz = new WidgetBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + ApiPagedResponse pr = await biz.GetManyAsync(Url, nameof(List), pagingOptions); + return Ok(new ApiOkWithPagingResponse(pr)); + } + + + + /// + /// Get widget pick list + /// + /// Required roles: + /// BizAdminFull, InventoryFull, BizAdminLimited, InventoryLimited, TechFull, TechLimited, Accounting + /// + /// This list supports querying the Name property + /// include a "q" parameter for string to search for + /// use % for wildcards. + /// + /// e.g. q=%Jones% + /// + /// Query is case insensitive + /// + /// Paged id/name collection of widgets with paging data + [HttpGet("PickList", Name = nameof(WidgetPickList))] + public async Task WidgetPickList([FromQuery] string q, [FromQuery] PagingOptions pagingOptions) + { + if (serverState.IsClosed) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Widget)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + WidgetBiz biz = new WidgetBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + ApiPagedResponse pr = await biz.GetPickListAsync(Url, nameof(WidgetPickList), pagingOptions, q); + return Ok(new ApiOkWithPagingResponse(pr)); + } + + + /// + /// Put (update) widget + /// + /// Required roles: + /// BizAdminFull, InventoryFull + /// TechFull (owned only) + /// + /// + /// + /// + /// + [HttpPut("{id}")] + public async Task PutWidget([FromRoute] long id, [FromBody] Widget inObj) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var oFromDb = await ct.Widget.SingleOrDefaultAsync(m => m.Id == id); + + if (oFromDb == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.Widget, oFromDb.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Instantiate the business object handler + WidgetBiz biz = new WidgetBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + if (!biz.Put(oFromDb, inObj)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!WidgetExists(id)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + else + { + //exists but was changed by another user + //I considered returning new and old record, but where would it end? + //Better to let the client decide what to do than to send extra data that is not required + return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); + } + } + + return Ok(new ApiOkResponse(new { ConcurrencyToken = oFromDb.ConcurrencyToken })); + } + + + + /// + /// Patch (update) widget + /// + /// Required roles: + /// BizAdminFull, InventoryFull + /// TechFull (owned only) + /// + /// + /// + /// + /// + [HttpPatch("{id}/{concurrencyToken}")] + public async Task PatchWidget([FromRoute] long id, [FromRoute] uint concurrencyToken, [FromBody]JsonPatchDocument objectPatch) + { + //https://dotnetcoretutorials.com/2017/11/29/json-patch-asp-net-core/ + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + WidgetBiz biz = new WidgetBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + + var oFromDb = await ct.Widget.SingleOrDefaultAsync(m => m.Id == id); + + if (oFromDb == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.Widget, oFromDb.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //patch and validate + if (!biz.Patch(oFromDb, objectPatch, concurrencyToken)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + + try + { + await ct.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + if (!WidgetExists(id)) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + else + { + return StatusCode(409, new ApiErrorResponse(ApiErrorCode.CONCURRENCY_CONFLICT)); + } + } + + return Ok(new ApiOkResponse(new { ConcurrencyToken = oFromDb.ConcurrencyToken })); + } + + + /// + /// Post widget + /// + /// Required roles: + /// BizAdminFull, InventoryFull, TechFull + /// + /// + /// + [HttpPost] + public async Task PostWidget([FromBody] Widget inObj) + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + //If a user has change roles, or editOwnRoles then they can create, true is passed for isOwner since they are creating so by definition the owner + if (!Authorized.IsAuthorizedToCreate(HttpContext.Items, AyaType.Widget)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + //Instantiate the business object handler + WidgetBiz biz = new WidgetBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + + //Create and validate + Widget o = await biz.CreateAsync(inObj); + + if (o == null) + { + //error return + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + else + { + //save and success return + await ct.SaveChangesAsync(); + return CreatedAtAction("GetWidget", new { id = o.Id }, new ApiCreatedResponse(o)); + } + } + + + + /// + /// Delete widget + /// + /// Required roles: + /// BizAdminFull, InventoryFull + /// TechFull (owned only) + /// + /// + /// + /// Ok + [HttpDelete("{id}")] + public async Task DeleteWidget([FromRoute] long id) + { + + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!ModelState.IsValid) + { + return BadRequest(new ApiErrorResponse(ModelState)); + } + + var dbObj = await ct.Widget.SingleOrDefaultAsync(m => m.Id == id); + if (dbObj == null) + { + return NotFound(new ApiErrorResponse(ApiErrorCode.NOT_FOUND)); + } + + if (!Authorized.IsAuthorizedToDelete(HttpContext.Items, AyaType.Widget, dbObj.OwnerId)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Instantiate the business object handler + WidgetBiz biz = new WidgetBiz(ct, UserIdFromContext.Id(HttpContext.Items), UserRolesFromContext.Roles(HttpContext.Items)); + if (!biz.Delete(dbObj)) + { + return BadRequest(new ApiErrorResponse(biz.Errors)); + } + await ct.SaveChangesAsync(); + + //Delete children / attached objects + biz.DeleteChildren(dbObj); + + return NoContent(); + } + + + + + private bool WidgetExists(long id) + { + return ct.Widget.Any(e => e.Id == id); + } + + + /// + /// Get route that triggers exception for testing + /// + /// Nothing, triggers exception + [HttpGet("exception")] + public ActionResult GetException() + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Widget)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + throw new System.NotSupportedException("Test exception from widget controller"); + } + + /// + /// Get route that triggers an alternate type of exception for testing + /// + /// Nothing, triggers exception + [HttpGet("altexception")] + public ActionResult GetAltException() + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToRead(HttpContext.Items, AyaType.Widget)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + throw new System.ArgumentException("Test exception (ALT) from widget controller"); + } + + + /// + /// Get route that submits a long running operation job for testing + /// + /// Nothing + [HttpGet("TestWidgetJob")] + public ActionResult TestWidgetJob() + { + if (!serverState.IsOpen) + { + return StatusCode(503, new ApiErrorResponse(ApiErrorCode.API_CLOSED, null, serverState.Reason)); + } + + if (!Authorized.IsAuthorizedToModify(HttpContext.Items, AyaType.JobOperations)) + { + return StatusCode(401, new ApiNotAuthorizedResponse()); + } + + //Create the job here + OpsJob j = new OpsJob(); + j.Name = "TestWidgetJob"; + j.JobType = JobType.TestWidgetJob; + JobsBiz.AddJob(j, ct); + return Accepted(new { JobId = j.GId });//202 accepted + } + + //------------ + + + } +} \ No newline at end of file diff --git a/server/AyaNova/Program.cs b/server/AyaNova/Program.cs new file mode 100644 index 00000000..7219126c --- /dev/null +++ b/server/AyaNova/Program.cs @@ -0,0 +1,246 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using NLog.Web; +using NLog.Targets; +using NLog.Config; + +using App.Metrics; +using App.Metrics.AspNetCore; + +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; + +namespace AyaNova +{ + + public class Program + { + + public static void Main(string[] args) + { + + //Get config + var config = new ConfigurationBuilder().AddEnvironmentVariables().AddCommandLine(args).Build(); + ServerBootConfig.SetConfiguration(config); + + #region Initialize Logging + + //default log level + NLog.LogLevel NLogLevel = NLog.LogLevel.Info; + bool logLevelIsInfoOrHigher = true; + + switch (ServerBootConfig.AYANOVA_LOG_LEVEL.ToLowerInvariant()) + { + case "fatal": + NLogLevel = NLog.LogLevel.Fatal; + break; + case "error": + NLogLevel = NLog.LogLevel.Error; + break; + case "warn": + NLogLevel = NLog.LogLevel.Warn; + break; + case "info": + NLogLevel = NLog.LogLevel.Info; + break; + case "debug": + NLogLevel = NLog.LogLevel.Debug; + logLevelIsInfoOrHigher = false; + break; + case "trace": + NLogLevel = NLog.LogLevel.Trace; + logLevelIsInfoOrHigher = false; + break; + default: + NLogLevel = NLog.LogLevel.Info; + break; + } + + + // Step 1. Create configuration object + var logConfig = new LoggingConfiguration(); + + // Step 2. Create targets and add them to the configuration + var fileTarget = new FileTarget(); + logConfig.AddTarget("file", fileTarget); + + //console target for really serious errors only + var consoleTarget = new ConsoleTarget(); + logConfig.AddTarget("console", consoleTarget); + + var nullTarget = new NLog.Targets.NullTarget(); + logConfig.AddTarget("blackhole", nullTarget); + + // Step 3. Set target properties + + fileTarget.FileName = Path.Combine(ServerBootConfig.AYANOVA_LOG_PATH, "log-ayanova.txt"); + fileTarget.Layout = "${longdate}|${uppercase:${level}}|${logger}|${message:exceptionSeparator==>:withException=true}"; + fileTarget.ArchiveFileName = Path.Combine(ServerBootConfig.AYANOVA_LOG_PATH, "log-ayanova-{#}.txt"); + fileTarget.ArchiveEvery = FileArchivePeriod.Wednesday; + fileTarget.MaxArchiveFiles = 3; + + // Step 4. Define rules + + //filter out all Microsoft INFO level logs as they are too much + var logRuleFilterOutMicrosoft = new LoggingRule("Microsoft.*", NLog.LogLevel.Trace, NLog.LogLevel.Info, nullTarget); + logRuleFilterOutMicrosoft.Final = true; + + //filter out all Microsoft EF CORE concurrency exceptions, it's a nuisance unless debugging or something + //This is what I have to filter because it's the top exception: Microsoft.EntityFrameworkCore.Update + //But this is what I'm actually trying to filter: Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException + //however there doesn't appear to be a way to filter out based on content so... + + var logRuleFilterOutMicrosoftEfCoreConcurrencyExceptions = new LoggingRule("Microsoft.EntityFrameworkCore.Update", NLog.LogLevel.Trace, NLog.LogLevel.Error, nullTarget); + logRuleFilterOutMicrosoftEfCoreConcurrencyExceptions.Final = true; + + //Log all other regular items at selected level + var logRuleAyaNovaItems = new LoggingRule("*", NLogLevel, fileTarget); + + //Log error or above to console + var logRuleForConsole = new LoggingRule("*", NLog.LogLevel.Error, consoleTarget); + + //add console serious error only log rule + logConfig.LoggingRules.Add(logRuleForConsole); + + //only log microsoft stuff it log is debug level or lower + if (logLevelIsInfoOrHigher) + { + //filter OUT microsoft stuff + logConfig.LoggingRules.Add(logRuleFilterOutMicrosoft); + logConfig.LoggingRules.Add(logRuleFilterOutMicrosoftEfCoreConcurrencyExceptions); + } + + logConfig.LoggingRules.Add(logRuleAyaNovaItems); + + + //Turn on internal logging: + if (ServerBootConfig.AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG) + { + NLog.Common.InternalLogger.LogFile = "log-ayanova-logger.txt"; + NLog.Common.InternalLogger.LogLevel = NLog.LogLevel.Debug; + } + + // NLog: setup the logger first to catch all errors + var logger = NLogBuilder.ConfigureNLog(logConfig).GetCurrentClassLogger(); + + //This is the first log entry + logger.Info("AYANOVA SERVER BOOTING (log level: \"{0}\")", ServerBootConfig.AYANOVA_LOG_LEVEL); + logger.Info(AyaNovaVersion.FullNameAndVersion); + logger.Debug("BOOT: Log path is \"{0}\" ", ServerBootConfig.AYANOVA_LOG_PATH); + + if (ServerBootConfig.AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG) + { + logger.Warn("BOOT: AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG is enabled! Disable as soon as no longer required."); + } + + //Log environmental settings + logger.Info("BOOT: OS - {0}", Environment.OSVersion.ToString()); + logger.Debug("BOOT: Machine - {0}", Environment.MachineName); + logger.Debug("BOOT: User - {0}", Environment.UserName); + logger.Debug("BOOT: .Net Version - {0}", Environment.Version.ToString()); + logger.Debug("BOOT: CPU count - {0}", Environment.ProcessorCount); + logger.Debug("BOOT: Default language - \"{0}\"", ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE); + + #endregion + + + //Ensure we are in the correct folder + string startFolder = Directory.GetCurrentDirectory(); + var wwwRootFolder = Path.Combine(startFolder, "wwwroot"); + + + //Test for web root path + //If user starts AyaNova from folder that is not the contentRoot then + //AyaNova won't be able to serve static files + if (!Directory.Exists(wwwRootFolder)) + { + var err = string.Format("BOOT: E1010 - AyaNova was not started in the correct folder. AyaNova must be started from the folder that contains the \"wwwroot\" folder but was started instead from this folder: \"{0}\" which does not contain the wwwroot folder.", startFolder); + logger.Fatal(err); + throw new System.ApplicationException(err); + } + + try + { + BuildWebHost(args, logger).Run(); + } + catch (Exception e) + { + logger.Fatal(e, "BOOT: E1090 - AyaNova server can't start due to unexpected exception during initialization"); + throw; + } + } + + + + public static IWebHost BuildWebHost(string[] args, NLog.Logger logger) + { + logger.Debug("BOOT: building web host"); + var configuration = new ConfigurationBuilder().AddCommandLine(args).Build(); + + return WebHost.CreateDefaultBuilder(args) + .CaptureStartupErrors(true) + .UseSetting("detailedErrors", "true") + .UseUrls(ServerBootConfig.AYANOVA_USE_URLS)//default port and urls, set first can be overridden by any later setting here + .UseConfiguration(configuration)//support command line override of port (dotnet run urls=http://*:8081) + .UseIISIntegration()//support IIS integration just in case, it appears here to override prior settings if necessary (port) + .ConfigureMetricsWithDefaults(builder => + { + if (ServerBootConfig.AYANOVA_METRICS_USE_INFLUXDB) + { + builder.Report.ToInfluxDb( + options => + { + + options.InfluxDb.BaseUri = new Uri(ServerBootConfig.AYANOVA_METRICS_INFLUXDB_BASEURL); + options.InfluxDb.Database = ServerBootConfig.AYANOVA_METRICS_INFLUXDB_DBNAME; + if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_METRICS_INFLUXDB_CONSISTENCY)) + { + options.InfluxDb.Consistenency = ServerBootConfig.AYANOVA_METRICS_INFLUXDB_CONSISTENCY; + } + options.InfluxDb.UserName = ServerBootConfig.AYANOVA_METRICS_INFLUXDB_USERNAME; + options.InfluxDb.Password = ServerBootConfig.AYANOVA_METRICS_INFLUXDB_PASSWORD; + if (!string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_METRICS_INFLUXDB_RETENSION_POLICY)) + { + options.InfluxDb.RetensionPolicy = ServerBootConfig.AYANOVA_METRICS_INFLUXDB_RETENSION_POLICY; + } + + options.InfluxDb.CreateDataBaseIfNotExists = true; + options.HttpPolicy.BackoffPeriod = TimeSpan.FromSeconds(30); + options.HttpPolicy.FailuresBeforeBackoff = 5; + options.HttpPolicy.Timeout = TimeSpan.FromSeconds(10); + //options.MetricsOutputFormatter = new App.Metrics.Formatters.Json.MetricsJsonOutputFormatter(); + //options.Filter = filter; + options.FlushInterval = TimeSpan.FromSeconds(20); + }); + } + + }) + .UseMetricsEndpoints(opt => + { + opt.EnvironmentInfoEndpointEnabled = false; + opt.MetricsEndpointEnabled = false; + opt.MetricsTextEndpointEnabled = false; + }) + .UseMetrics() + .UseStartup() + .ConfigureLogging((context, logging) => + { + // clear all previously registered providers + //https://stackoverflow.com/a/46336988/8939 + logging.ClearProviders(); + }) + .UseNLog() // NLog: setup NLog for Dependency injection + .Build(); + } + + }//eoc + +}//eons diff --git a/server/AyaNova/Startup.cs b/server/AyaNova/Startup.cs new file mode 100644 index 00000000..cef62082 --- /dev/null +++ b/server/AyaNova/Startup.cs @@ -0,0 +1,439 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Extensions.Hosting; + +using AyaNova.Models; +using AyaNova.Util; +using AyaNova.Generator; +using AyaNova.Biz; + +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerGen; +using Swashbuckle.AspNetCore.SwaggerUI; + +using System.IO; +using System.Reflection; +using System.Linq; +using System; + + + +namespace AyaNova +{ + + public class Startup + { + + + ///////////////////////////////////////////////////////////// + // + public Startup(ILogger logger, ILoggerFactory logFactory, Microsoft.AspNetCore.Hosting.IHostingEnvironment hostingEnvironment) + { + _log = logger; + _hostingEnvironment = hostingEnvironment; + AyaNova.Util.ApplicationLogging.LoggerFactory = logFactory; + //this must be set here + ServerBootConfig.AYANOVA_CONTENT_ROOT_PATH=hostingEnvironment.ContentRootPath; + + } + + private readonly ILogger _log; + private string _connectionString = ""; + private readonly Microsoft.AspNetCore.Hosting.IHostingEnvironment _hostingEnvironment; + + //////////////////////////////////////////////////////////// + // This method gets called by the runtime. Use this method to add services to the container. + // + public void ConfigureServices(IServiceCollection services) + { + _log.LogDebug("BOOT: initializing services..."); + + //Server state service for shutting people out of api + _log.LogDebug("BOOT: init ApiServerState service"); + services.AddSingleton(new AyaNova.Api.ControllerHelpers.ApiServerState()); + + // add the versioned api explorer, which also adds IApiVersionDescriptionProvider service + // note: the specified format code will format the version as "'v'major[.minor][-status]" + _log.LogDebug("BOOT: init ApiExplorer service"); + services.AddMvcCore().AddVersionedApiExplorer(o => o.GroupNameFormat = "'v'VVV"); + + _log.LogDebug("BOOT: ensuring user and backup folders exist and are separate locations..."); + FileUtil.EnsureUserAndUtilityFoldersExistAndAreNotIdentical(_hostingEnvironment.ContentRootPath); + + #region DATABASE + _connectionString = ServerBootConfig.AYANOVA_DB_CONNECTION; + + //Check DB server exists and can be connected to + _log.LogDebug("BOOT: Testing database server connection..."); + + //parse the connection string properly + DbUtil.ParseConnectionString(_log, _connectionString); + + //Test for server + //Will retry 10 times every 3 seconds for a total of 30 seconds + if (!DbUtil.DatabaseServerExists(_log, "BOOT: waiting for db server")) + { + var err = $"BOOT: E1000 - AyaNova can't connect to the database server after trying for 30 seconds (connection string is:\"{DbUtil.DisplayableConnectionString}\")"; + _log.LogCritical(err); + throw new System.ApplicationException(err); + } + + + + + + + //ensure database is ready and present + DbUtil.EnsureDatabaseExists(_log); + + bool LOG_SENSITIVE_DATA = false; + +#if (DEBUG) + //LOG_SENSITIVE_DATA = true; + +#endif + + _log.LogDebug("BOOT: init EF service"); + services.AddEntityFrameworkNpgsql().AddDbContext( + options => options.UseNpgsql(_connectionString, + opt => opt.EnableRetryOnFailure())//http://www.npgsql.org/efcore/misc.html?q=execution%20strategy#execution-strategy + .ConfigureWarnings(warnings => //https://livebook.manning.com/#!/book/entity-framework-core-in-action/chapter-12/v-10/85 + warnings.Throw( //Throw an exception on client eval, not necessarily an error but a smell + Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.QueryClientEvaluationWarning)) + .EnableSensitiveDataLogging(LOG_SENSITIVE_DATA) + ); + + + + + #endregion + + _log.LogDebug("BOOT: init ApiVersioning service"); + // services.AddApiVersioning(o => o.ReportApiVersions = true); + services + .AddApiVersioning(options => + { + options.AssumeDefaultVersionWhenUnspecified = true; + options.DefaultApiVersion = Microsoft.AspNetCore.Mvc.ApiVersion.Parse("8.0"); + options.ReportApiVersions = true; + }); + + + _log.LogDebug("BOOT: init MVC service"); + _log.LogDebug("BOOT: init Metrics service"); + + + services.AddMvc(config => + { + //was this but needed logging, not certain about the new way of adding so keeping this in case it all goes sideways in testing + //config.Filters.Add(typeof(AyaNova.Api.ControllerHelpers.ApiCustomExceptionFilter)); + config.Filters.Add(new AyaNova.Api.ControllerHelpers.ApiCustomExceptionFilter(AyaNova.Util.ApplicationLogging.LoggerFactory)); + + }).AddMetrics(); + + + #region Swagger + //https://docs.microsoft.com/en-us/aspnet/core/tutorials/web-api-help-pages-using-swagger?tabs=visual-studio-code + //https://swagger.io/ + //https://github.com/domaindrivendev/Swashbuckle.AspNetCore + + _log.LogDebug("BOOT: init API explorer service"); + services.AddSwaggerGen( + c => + { + // resolve the IApiVersionDescriptionProvider service + // note: that we have to build a temporary service provider here because one has not been created yet + var provider = services.BuildServiceProvider().GetRequiredService(); + + // add a swagger document for each discovered API version + // note: you might choose to skip or document deprecated API versions differently + foreach (var description in provider.ApiVersionDescriptions) + { + c.SwaggerDoc(description.GroupName, CreateInfoForApiVersion(description)); + } + + // add a custom operation filter which sets default values + c.OperationFilter(); + + // integrate xml comments + c.IncludeXmlComments(XmlCommentsFilePath); + + + + //this is required to allow authentication when testing secure routes via swagger UI + c.AddSecurityDefinition("Bearer", new ApiKeyScheme + { + Description = "JWT Authorization header using the Bearer scheme. Get your token by logging in via the Auth route then enter it here with the \"Bearer \" prefix. Example: \"Bearer {token}\"", + Name = "Authorization", + In = "header", + Type = "apiKey" + + }); + + + c.AddSecurityRequirement(new System.Collections.Generic.Dictionary> + { + { "Bearer", new string[] { } } + }); + + + + }); + + + #endregion + + + #region JWT AUTHENTICATION + //get the key if specified + var secretKey = ServerBootConfig.AYANOVA_JWT_SECRET; + + //If no key specified make a unique one based on license ID which is unique to each customer or trialler + if (string.IsNullOrWhiteSpace(secretKey)) + { + // This ensures a key can't work on another AyaNova installation + if (AyaNova.Core.License.ActiveKey.TrialLicense) + secretKey = AyaNova.Core.License.ActiveKey.Id + "5G*QQJ8#bQ7$Xr_@sXfHq4"; + else + secretKey = AyaNova.Core.License.ActiveKey.RegisteredTo + "5G*QQJ8#bQ7$Xr_@sXfHq4"; + } + + //If secretKey is less than 32 characters, pad it + if (secretKey.Length < 32) + { + secretKey = secretKey.PadRight(32, '-'); + } + + ServerBootConfig.AYANOVA_JWT_SECRET = secretKey; + var signingKey = new SymmetricSecurityKey(System.Text.Encoding.ASCII.GetBytes(ServerBootConfig.AYANOVA_JWT_SECRET)); + + _log.LogDebug("BOOT: init Authorization service"); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }).AddJwtBearer(options => + { + // options.AutomaticAuthenticate = true; + // options.AutomaticChallenge = true; + options.TokenValidationParameters = new TokenValidationParameters + { + // Token signature will be verified using a private key. + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + IssuerSigningKey = signingKey, + ValidateIssuer = true, + ValidIssuer = "AyaNova", + ValidateAudience = false, + //ValidAudience = "http://localhost:7575/" + + // Token will only be valid if not expired yet, with 5 minutes clock skew. + ValidateLifetime = true, + RequireExpirationTime = true, + ClockSkew = new TimeSpan(0, 5, 0), + }; + }); + + + + #endregion + + _log.LogDebug("BOOT: init Generator service"); + services.AddSingleton(); + + + } + + + + //////////////////////////////////////////////////////////// + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. + // + public void Configure(IApplicationBuilder app, Microsoft.AspNetCore.Hosting.IHostingEnvironment env, + AyContext dbContext, IApiVersionDescriptionProvider provider, AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, IServiceProvider serviceProvider) + { + _log.LogDebug("BOOT: configuring request pipeline..."); + + + //Store a reference to the dependency injection service for static classes + ServiceProviderProvider.Provider = app.ApplicationServices; + + //Enable ability to handle reverse proxy + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto + }); + + + #region SWAGGER + + _log.LogDebug("BOOT: pipeline - api explorer"); + // Enable middleware to serve generated Swagger as a JSON endpoint. + app.UseSwagger(); + + app.UseSwaggerUI(c => + { + // build a swagger endpoint for each discovered API version + foreach (var description in provider.ApiVersionDescriptions) + { + c.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant()); + } + + //clean up the swagger explorer UI page and remove the branding + //via our own css + //NOTE: this broke when updated to v2.x of swagger and it can be fixed according to docs: + //https://github.com/domaindrivendev/Swashbuckle.AspNetCore#inject-custom-css + // c.InjectStylesheet("/api/sw.css"); + + c.DefaultModelsExpandDepth(-1); + c.DocumentTitle = "AyaNova API explorer"; + c.RoutePrefix = "api-docs"; + }); + #endregion swagger + + #region STATIC FILES + _log.LogDebug("BOOT: pipeline - static files"); + app.UseDefaultFiles(); + app.UseStaticFiles(new StaticFileOptions + { + OnPrepareResponse = context => + { + if (context.File.Name == "default.htm") + { + context.Context.Response.Headers.Add("Cache-Control", "no-cache, no-store"); + context.Context.Response.Headers.Add("Expires", "-1"); + } + } + }); + #endregion + + #region AUTH / ROLES + _log.LogDebug("BOOT: pipeline - authentication"); + //Use authentication middleware + app.UseAuthentication(); + + //Custom middleware to get user roles and put them into the request so + //they can be authorized in routes + app.Use(async (context, next) => + { + if (!context.User.Identity.IsAuthenticated) + { + context.Request.HttpContext.Items["AY_ROLES"] = 0; + } + else + { + //Get user ID from claims + long userId = Convert.ToInt64(context.User.FindFirst(c => c.Type == "id").Value); + + //Get the database context + var ct = context.RequestServices.GetService(); + + //get the user record + var u = ct.User.AsNoTracking().Where(a => a.Id == userId).Select(m => new { roles = m.Roles, name = m.Name, Id = m.Id }).First(); + context.Request.HttpContext.Items["AY_ROLES"] = u.roles; + context.Request.HttpContext.Items["AY_USERNAME"] = u.name; + context.Request.HttpContext.Items["AY_USER_ID"] = u.Id; + } + await next.Invoke(); + }); + + #endregion + + + //USE MVC + _log.LogDebug("BOOT: pipeline - MVC"); + app.UseMvc(); + + + if (ServerBootConfig.AYANOVA_PERMANENTLY_ERASE_DATABASE) + { + _log.LogWarning("BOOT: AYANOVA_PERMANENTLY_ERASE_DATABASE is true, dropping and recreating database"); + Util.DbUtil.DropAndRecreateDb(_log); + AySchema.CheckAndUpdate(dbContext, _log); + } + + //Check schema + _log.LogDebug("BOOT: db schema check"); + AySchema.CheckAndUpdate(dbContext, _log); + + //Check database integrity + _log.LogDebug("BOOT: db integrity check"); + DbUtil.CheckFingerPrint(AySchema.EXPECTED_COLUMN_COUNT, AySchema.EXPECTED_INDEX_COUNT, _log); + + + + //Initialize license + AyaNova.Core.License.Initialize(apiServerState, dbContext, _log); + + //Ensure locales are present, not missing any keys and that there is a server default locale that exists + LocaleBiz lb = new LocaleBiz(dbContext, 1, AuthorizationRoles.OpsAdminFull); + lb.ValidateLocales(); + +#if (DEBUG) + // Util.DbUtil.DropAndRecreateDb(_log); + // AySchema.CheckAndUpdate(dbContext, _log); + // lb.ValidateLocales(); + // AyaNova.Core.License.Initialize(apiServerState, dbContext, _log); + // AyaNova.Core.License.Fetch(apiServerState, dbContext, _log); + // Util.Seeder.SeedDatabase(dbContext, Util.Seeder.SeedLevel.SmallOneManShopTrialDataSet); + +#endif + + + + + + + //Open up the server for visitors + apiServerState.SetOpen(); + + //final startup log + _log.LogInformation("BOOT: COMPLETED - SERVER IS NOW OPEN"); + + } + + + #region Swagger and API Versioning utilities + + static string XmlCommentsFilePath + { + get + { + //Obsolete, used new method: https://developers.de/blogs/holger_vetter/archive/2017/06/30/swagger-includexmlcomments-platformservices-obsolete-replacement.aspx + //var basePath = PlatformServices.Default.Application.ApplicationBasePath; + var basePath = AppContext.BaseDirectory; + var fileName = typeof(Startup).GetTypeInfo().Assembly.GetName().Name + ".xml"; + return Path.Combine(basePath, fileName); + } + } + + static Info CreateInfoForApiVersion(ApiVersionDescription description) + { + var info = new Info() + { + Title = $"AyaNova API {description.ApiVersion}", + Version = description.ApiVersion.ToString() + }; + + if (description.IsDeprecated) + { + info.Description += " This API version has been deprecated."; + } + + return info; + } + #endregion + + + } +} + + diff --git a/server/AyaNova/SwaggerDefaultValues.cs b/server/AyaNova/SwaggerDefaultValues.cs new file mode 100644 index 00000000..14d01e0d --- /dev/null +++ b/server/AyaNova/SwaggerDefaultValues.cs @@ -0,0 +1,47 @@ +namespace AyaNova +{ + using Swashbuckle.AspNetCore.Swagger; + using Swashbuckle.AspNetCore.SwaggerGen; + using System.Linq; + + /// + /// Represents the Swagger/Swashbuckle operation filter used to document the implicit API version parameter. + /// + /// This is only required due to bugs in the . + /// Once they are fixed and published, this class can be removed. + public class SwaggerDefaultValues : IOperationFilter + { + /// + /// Applies the filter to the specified operation using the given context. + /// + /// The operation to apply the filter to. + /// The current operation filter context. + public void Apply( Operation operation, OperationFilterContext context ) + { + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412 + // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413 + foreach ( var parameter in operation.Parameters.OfType() ) + { + var description = context.ApiDescription.ParameterDescriptions.First( p => p.Name == parameter.Name ); + var routeInfo = description.RouteInfo; + + if ( parameter.Description == null ) + { + parameter.Description = description.ModelMetadata?.Description; + } + + if ( routeInfo == null ) + { + continue; + } + + if ( parameter.Default == null ) + { + parameter.Default = routeInfo.DefaultValue; + } + + parameter.Required |= !routeInfo.IsOptional; + } + } + } +} \ No newline at end of file diff --git a/server/AyaNova/appsettings.Development.json b/server/AyaNova/appsettings.Development.json new file mode 100644 index 00000000..8a42341c --- /dev/null +++ b/server/AyaNova/appsettings.Development.json @@ -0,0 +1,10 @@ +{ + "Logging": { + "IncludeScopes": false, + "LogLevel": { + "Default": "Trace", + "System": "Trace", + "Microsoft": "Trace" + } + } +} diff --git a/server/AyaNova/appsettings.json b/server/AyaNova/appsettings.json new file mode 100644 index 00000000..5f4a8ddb --- /dev/null +++ b/server/AyaNova/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "IncludeScopes": false, + "Debug": { + "LogLevel": { + "Default": "Trace" + } + }, + "Console": { + "LogLevel": { + "Default": "Trace" + } + } + } + + +} diff --git a/server/AyaNova/biz/AttachableAttribute.cs b/server/AyaNova/biz/AttachableAttribute.cs new file mode 100644 index 00000000..e96ed33f --- /dev/null +++ b/server/AyaNova/biz/AttachableAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace AyaNova.Biz +{ + /// + /// Marker attribute indicating that an object supports attachments + /// Used in + /// + [AttributeUsage(AttributeTargets.All)] + public class AttachableAttribute : Attribute + { + //No code required, it's just a marker + //https://docs.microsoft.com/en-us/dotnet/standard/attributes/writing-custom-attributes + } +}//eons diff --git a/server/AyaNova/biz/AuthorizationRoles.cs b/server/AyaNova/biz/AuthorizationRoles.cs new file mode 100644 index 00000000..58b19e25 --- /dev/null +++ b/server/AyaNova/biz/AuthorizationRoles.cs @@ -0,0 +1,56 @@ +using System; + +namespace AyaNova.Biz +{ + /// + /// Authorization roles + /// + [Flags] + public enum AuthorizationRoles : int + { + //https://stackoverflow.com/questions/8447/what-does-the-flags-enum-attribute-mean-in-c + //MAX 32!!! or will overflow int and needs to be turned into a long + //Must be a power of two: https://en.wikipedia.org/wiki/Power_of_two + + ///No role set + NoRole = 0, + ///BizAdminLimited + BizAdminLimited = 1, + ///BizAdminFull + BizAdminFull = 2, + ///DispatchLimited + DispatchLimited = 4, + ///DispatchFull + DispatchFull = 8, + ///InventoryLimited + InventoryLimited = 16, + ///InventoryFull + InventoryFull = 32, + ///AccountingFull + AccountingFull = 64,//No limited role, not sure if there is a need + ///TechLimited + TechLimited = 128, + ///TechFull + TechFull = 256, + ///SubContractorLimited + SubContractorLimited = 512, + ///SubContractorFull + SubContractorFull = 1024, + ///ClientLimited + ClientLimited = 2048, + ///ClientFull + ClientFull = 4096, + ///OpsAdminLimited + OpsAdminLimited = 8192, + ///OpsAdminFull + OpsAdminFull = 16384, + + ///Anyone of any role + AnyRole = BizAdminLimited | BizAdminFull | DispatchLimited | DispatchFull | InventoryLimited | + InventoryFull | AccountingFull | TechLimited | TechFull | SubContractorLimited | + SubContractorFull | ClientLimited | ClientFull | OpsAdminLimited | OpsAdminFull + + }//end SecurityLevelTypes + +}//end namespace GZTW.AyaNova.BLL + diff --git a/server/AyaNova/biz/AyaObjectOwnerId.cs b/server/AyaNova/biz/AyaObjectOwnerId.cs new file mode 100644 index 00000000..db4ea899 --- /dev/null +++ b/server/AyaNova/biz/AyaObjectOwnerId.cs @@ -0,0 +1,48 @@ +using AyaNova.Models; +using AyaNova.Biz; + +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using System.Reflection; +using System; + + +namespace AyaNova.Biz +{ + + /// + /// Returns owner Id if the object exists or 0 if exists but there is no owner ID property or -1 if the object doesn't exist + /// + internal static class AyaObjectOwnerId + { + internal static long Get(AyaTypeId o, AyContext ct) + { + if (o.IsEmpty) return -1; + + + //Get the type of the model of AyaObject + Type t = Type.GetType("AyaNova.Models." + o.ObjectType.ToString()); + + //Run a find query on the db context based on the model's type + object record = ct.Find(t, o.ObjectId); + if (record == null) + { + return -1; + } + + + PropertyInfo ownerIdPropertyInfo = record.GetType().GetProperty("OwnerId"); + + if (ownerIdPropertyInfo == null) + return 0;//object exists and it doesn't have an ownerID property + + + long ret = (long)ownerIdPropertyInfo.GetValue(record, null); + + return ret; + + } + + } + +}//eons diff --git a/server/AyaNova/biz/AyaType.cs b/server/AyaNova/biz/AyaType.cs new file mode 100644 index 00000000..5bebd5e3 --- /dev/null +++ b/server/AyaNova/biz/AyaType.cs @@ -0,0 +1,156 @@ +namespace AyaNova.Biz +{ + + + /// + /// All AyaNova types and their attributes indicating what features that type will support (tagging, attachments etc) + /// + public enum AyaType : int + { + NotValid = 0, + Global = 1, + + [Attachable, Taggable] + Widget = 2, + + [Attachable, Taggable] + User = 3, + + ServerState = 4, + License = 5, + LogFile = 6, + Tag = 7, + TagMap = 8, + JobOperations = 9, + AyaNova7Import = 10, + TrialSeeder = 11, + Metrics = 12, + Locale = 13 + + } + + +}//eons + +/* + +/////////////////////////////////////////////////////////// +// RootObjectTypes.cs +// Implementation of Class RootObjectTypes +// CSLA type: enumeration +// Created on: 07-Jun-2004 8:41:36 AM +// Object design: Joyce Class Incomplete +/////////////////////////////////////////////////////////// + +using System.ComponentModel; + + +namespace GZTW.AyaNova.BLL +{ + + /// + /// RootObject types. + /// Note that some items here are not strictly root + /// objects, but are included because they need to be identified + /// for other purposes such as indexed keywords etc. + /// + public enum RootObjectTypes : int { + + [Description("LT:O.Nothing")] Nothing = 0, + [Description("LT:O.Global")] Global = 1, + [Description("LT:O.Region")] Region = 2, + [Description("LT:O.Client")] Client = 3, + [Description("LT:O.Vendor")] Vendor = 4, + [Description("LT:O.HeadOffice")] HeadOffice = 5, + [Description("LT:O.RentalUnit")] RentalUnit = 6, + [Description("LT:O.Unit")] Unit = 7, + [Description("LT:O.UnitModel")] UnitModel = 8, + [Description("LT:O.Workorder")] Workorder = 9, + [Description("LT:O.WorkorderItem")] WorkorderItem = 10, + [Description("LT:O.UserSkillAssigned")] UserSkillAssigned = 11, + [Description("LT:O.UserCertificationAssigned")] UserCertificationAssigned = 12, + [Description("LT:O.User")] User = 13, + [Description("LT:O.Part")] Part = 14, + [Description("LT:O.LoanItem")] LoanItem = 15, + [Description("LT:O.DispatchZone")] DispatchZone = 16, + [Description("LT:O.Rate")] Rate = 17, + [Description("LT:O.Contract")] Contract = 18, + [Description("LT:O.Project")] Project = 19, + [Description("LT:O.PurchaseOrder")] PurchaseOrder = 20, + [Description("LT:O.ClientGroup")] ClientGroup = 21, + [Description("LT:O.WorkorderCategory")] WorkorderCategory = 22, + [Description("LT:O.WorkorderItemScheduledUser")] WorkorderItemScheduledUser = 23, + [Description("LT:O.WorkorderItemOutsideService")] WorkorderItemOutsideService = 24, + [Description("LT:O.WorkorderItemPart")] WorkorderItemPart = 25, + [Description("LT:O.WorkorderItemLabor")] WorkorderItemLabor = 26, + [Description("LT:O.WorkorderItemTravel")] WorkorderItemTravel = 27, + [Description("LT:O.WorkorderItemMiscExpense")] WorkorderItemMiscExpense = 28, + [Description("LT:O.WorkorderItemPartRequest")] WorkorderItemPartRequest = 29, + [Description("LT:O.WorkorderItemLoan")] WorkorderItemLoan = 30, + [Description("LT:O.ClientNote")] ClientNote = 31, + [Description("LT:O.ServiceBank")] ServiceBank = 32, + [Description("LT:O.WorkorderQuote")] WorkorderQuote = 33, + [Description("LT:O.WorkorderService")] WorkorderService = 34, + [Description("LT:O.AssignedDoc")] AssignedDocument = 35, + [Description("LT:O.PartWarehouse")] PartWarehouse = 36, + [Description("LT:O.UnitMeterReading")] UnitMeterReading = 37, + [Description("LT:O.UnitModelCategory")] UnitModelCategory = 38, + [Description("LT:O.Locale")] Locale = 39, + [Description("LT:O.SearchResult")] SearchResult = 40, + [Description("LT:O.WorkorderItemType")] WorkorderItemType = 41, + [Description("LT:O.UnitServiceType")] UnitServiceType = 42, + [Description("LT:O.PartAssembly")] PartAssembly = 43, + [Description("LT:O.AyaFile")] AyaFile = 44,//case 73 + [Description("LT:O.Contact")] Contact = 45, + [Description("LT:O.ContactPhone")] ContactPhone = 46, + [Description("LT:O.WorkorderPreventiveMaintenance")] WorkorderPreventiveMaintenance = 47, + [Description("LT:O.TaskGroup")] TaskGroup = 48, + [Description("LT:O.ScheduleMarker")] ScheduleMarker = 49, + [Description("LT:O.ClientServiceRequest")] ClientServiceRequest = 50, + [Description("LT:O.ScheduleableUserGroup")] ScheduleableUserGroup = 51, + [Description("LT:O.Task")] Task = 52, + [Description("LT:O.Memo")] Memo = 53, + [Description("LT:O.PartCategory")] PartCategory=54, + [Description("LT:O.UnitOfMeasure")] UnitOfMeasure=55, + [Description("LT:O.TaxCode")] TaxCode=56, + [Description("LT:O.PartSerial")] PartSerial = 57, + [Description("LT:O.PartInventoryAdjustment")] PartInventoryAdjustment = 58, + [Description("LT:O.PartInventoryAdjustmentItem")] PartInventoryAdjustmentItem = 59, + [Description("LT:O.Priority")] Priority=60, + [Description("LT:O.UserSkill")] UserSkill=61, + [Description("LT:O.WorkorderStatus")] WorkorderStatus=62, + [Description("LT:O.UserCertification")] UserCertification=63, + [Description("LT:O.ClientNoteType")] ClientNoteType=64, + [Description("LT:O.SecurityGroup")] SecurityGroup=65, + [Description("LT:O.PurchaseOrderReceiptItem")] PurchaseOrderReceiptItem=66, + [Description("LT:O.PartByWarehouseInventory")] PartByWarehouseInventory=67, + [Description("LT:O.Report")] Report=68, + [Description("LT:O.WorkorderQuoteTemplate")] + WorkorderQuoteTemplate = 69, + [Description("LT:O.WorkorderServiceTemplate")] + WorkorderServiceTemplate = 70, + [Description("LT:O.WorkorderPreventiveMaintenanceTemplate")] + WorkorderPreventiveMaintenanceTemplate = 71, + [Description("LT:O.WikiPage")]//case 73 + WikiPage = 72, + [Description("LT:O.GridFilter")]//case 941 + GridFilter = 73, + [Description("LT:O.NotifySubscription")]//case 941 + NotifySubscription = 74, + [Description("LT:O.PurchaseOrderReceipt")]//case 941 + PurchaseOrderReceipt = 75, + [Description("LT:O.Notification")]//case 1172 + Notification = 76, + [Description("LT:UI.Go.Schedule")]//case 812 + Schedule = 77, + [Description("LT:O.WorkorderItemTask")]//case 1317 + WorkorderItemTask = 78, + [Description("LT:O.WorkorderItemUnit")]//case 1317 + WorkorderItemUnit = 79, + [Description("LT:ScheduleMarker.Label.FollowUp")]//case 1975 + FollowUp = 80 + + }//end RootObjectTypes + +}//end namespace GZTW.AyaNova.BLL + */ diff --git a/server/AyaNova/biz/AyaTypeId.cs b/server/AyaNova/biz/AyaTypeId.cs new file mode 100644 index 00000000..dc02aa83 --- /dev/null +++ b/server/AyaNova/biz/AyaTypeId.cs @@ -0,0 +1,103 @@ +using System; +using AyaNova.Models; + +namespace AyaNova.Biz +{ + + public class AyaTypeId : System.Object + { + private long _id; + private AyaType _ayaType; + + public long ObjectId + { + get + { + return _id; + } + } + + public AyaType ObjectType + { + get + { + return _ayaType; + } + } + + public int ObjectTypeAsInt + { + get + { + return (int)_ayaType; + } + } + + public AyaTypeId(AyaType ObjectType, long Id) + { + _id = Id; + _ayaType = ObjectType; + } + + public AyaTypeId(string sObjectTypeNumeral, string sId) + { + _id = long.Parse(sId); + int nType = int.Parse(sObjectTypeNumeral); + if (!AyaTypeExists(nType)) + _ayaType = AyaType.NotValid; + else + _ayaType = (AyaType)Enum.Parse(typeof(AyaType), sObjectTypeNumeral); + } + + public bool IsEmpty + { + get + { + return (_ayaType == AyaType.NotValid) || (_id == 0); + } + } + + /// + /// Get the ownerId for the object in question + /// + /// db context + /// 0 if object doesn't have an owner Id, the owner Id or -1 if the object doesn't exist in the db + public long OwnerId(AyContext ct) + { + return AyaObjectOwnerId.Get(this, ct); + } + + + /// + /// Check if the numeric or name type value is an actual enum value + /// + /// + /// + public bool AyaTypeExists(int enumNumber) + { + return Enum.IsDefined(typeof(AyaType), enumNumber); + } + + + //Custom attribute checking + + /// + /// Is object attachable + /// + /// + public bool IsAttachable + { + get + { + return this.ObjectType.HasAttribute(typeof(AttachableAttribute)); + } + } + + + + + } + + +}//eons + diff --git a/server/AyaNova/biz/BizObject.cs b/server/AyaNova/biz/BizObject.cs new file mode 100644 index 00000000..5f7c8491 --- /dev/null +++ b/server/AyaNova/biz/BizObject.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text; +using AyaNova.Biz; + +namespace AyaNova.Biz +{ + + /// + /// Business object base class + /// + internal abstract class BizObject : IBizObject + { + + public BizObject() + { + + } + + #region Roles + + public AuthorizationRoles CurrentUserRoles { get; set; } + + + #endregion roles + + #region Error handling + private readonly List _errors = new List(); + + + + public List Errors => _errors; + + public bool HasErrors => _errors.Any(); + + // public void AddError(string errorMessage, string field="") + // { + // _errors.Add(new ValidationError() { Message = errorMessage, Target = field }); + // } + + public void AddvalidationError(ValidationError validationError) + { + _errors.Add(validationError); + } + + public bool PropertyHasErrors(string propertyName) + { + if (_errors.Count == 0) return false; + var v = _errors.FirstOrDefault(m => m.Target == propertyName); + return (v != null); + + } + + public void AddError(ValidationErrorType errorType, string propertyName = null, string errorMessage = null) + { + + _errors.Add(new ValidationError() { ErrorType = errorType, Message = errorMessage, Target = propertyName }); + } + + public string GetErrorsAsString() + { + if (!HasErrors) return string.Empty; + + StringBuilder sb = new StringBuilder(); + sb.AppendLine("Validation errors:"); + foreach (ValidationError e in _errors) + { + var msg=e.Message; + if(string.IsNullOrWhiteSpace(msg)){ + msg=e.ErrorType.ToString(); + } + sb.AppendLine($"Target: {e.Target} error: {msg}"); + } + return sb.ToString(); + } + + #endregion error handling + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/biz/BizObjectFactory.cs b/server/AyaNova/biz/BizObjectFactory.cs new file mode 100644 index 00000000..8614f87e --- /dev/null +++ b/server/AyaNova/biz/BizObjectFactory.cs @@ -0,0 +1,55 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; + + +namespace AyaNova.Biz +{ + + + internal static class BizObjectFactory + { + + + //Returns the biz object class that corresponds to the type presented + internal static BizObject GetBizObject(AyaType aytype, AyContext dbcontext, long userId = 1, AuthorizationRoles roles = AuthorizationRoles.AnyRole) + { + switch (aytype) + { + case AyaType.Widget: + return new WidgetBiz(dbcontext, userId, roles); + case AyaType.Tag: + return new TagBiz(dbcontext, userId, roles); + case AyaType.TagMap: + return new TagMapBiz(dbcontext, userId, roles); + case AyaType.JobOperations: + return new JobOperationsBiz(dbcontext, userId, roles); + case AyaType.AyaNova7Import: + return new ImportAyaNova7Biz(dbcontext, userId, roles); + case AyaType.TrialSeeder: + return new TrialBiz(dbcontext, userId, roles); + case AyaType.Locale: + return new LocaleBiz(dbcontext, userId, roles); + + + default: + throw new System.NotSupportedException($"AyaNova.BLL.BizObjectFactory::GetBizObject type {aytype.ToString()} is not supported"); + } + + } + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/biz/BizRoleSet.cs b/server/AyaNova/biz/BizRoleSet.cs new file mode 100644 index 00000000..985131ec --- /dev/null +++ b/server/AyaNova/biz/BizRoleSet.cs @@ -0,0 +1,15 @@ +namespace AyaNova.Biz +{ + +/// +/// This is a set of roles to be stored in the central BizRoles with a key for each object type +/// + public class BizRoleSet + { + public AuthorizationRoles Change { get; set; } + public AuthorizationRoles EditOwn { get; set; } + public AuthorizationRoles Read { get; set; } + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/biz/BizRoles.cs b/server/AyaNova/biz/BizRoles.cs new file mode 100644 index 00000000..3a3fa290 --- /dev/null +++ b/server/AyaNova/biz/BizRoles.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +namespace AyaNova.Biz +{ + + /// + /// roles of all business objects + /// + internal static class BizRoles + { + + static Dictionary roles = new Dictionary(); + + static BizRoles() + { + //Add all object roles here + //NOTE: do not need to add change roles to read roles, Authorized.cs takes care of that automatically + //by assuming if you can change you can read + #region All roles initialization + //////////////////////////////////////////////////////////// + //WIDGET + // + roles.Add(AyaType.Widget, new BizRoleSet() + { + Change = AuthorizationRoles.BizAdminFull | AuthorizationRoles.InventoryFull, + EditOwn = AuthorizationRoles.TechFull, + Read = AuthorizationRoles.BizAdminLimited | AuthorizationRoles.InventoryLimited | + AuthorizationRoles.TechFull | AuthorizationRoles.TechLimited | AuthorizationRoles.AccountingFull + }); + + //////////////////////////////////////////////////////////// + //SERVERSTATE + // + roles.Add(AyaType.ServerState, new BizRoleSet() + { + Change = AuthorizationRoles.OpsAdminFull, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.AnyRole + }); + + + //////////////////////////////////////////////////////////// + //LICENSE + // + roles.Add(AyaType.License, new BizRoleSet() + { + Change = AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminFull, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.BizAdminLimited | AuthorizationRoles.OpsAdminLimited + }); + + //////////////////////////////////////////////////////////// + //LOGFILE + // + roles.Add(AyaType.LogFile, new BizRoleSet() + { + Change = AuthorizationRoles.NoRole, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited + }); + + //////////////////////////////////////////////////////////// + //TAG + //Full roles can make new tags and can edit or delete existing tags + roles.Add(AyaType.Tag, new BizRoleSet() + { + Change = AuthorizationRoles.BizAdminFull | AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryFull | AuthorizationRoles.TechFull | AuthorizationRoles.AccountingFull, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.AnyRole + }); + + //////////////////////////////////////////////////////////// + //TAGMAP + //Any roles can tag objects and remove tags as per their rights to the taggable object type in question + roles.Add(AyaType.TagMap, new BizRoleSet() + { + Change = AuthorizationRoles.AnyRole, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.AnyRole + }); + + + //////////////////////////////////////////////////////////// + //OPERATIONS + //Only opsfull can change operations + //ops and biz admin can view operations + roles.Add(AyaType.JobOperations, new BizRoleSet() + { + Change = AuthorizationRoles.OpsAdminFull, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.OpsAdminLimited | AuthorizationRoles.BizAdminFull | AuthorizationRoles.BizAdminLimited + }); + + //////////////////////////////////////////////////////////// + //AyaNova7Import + //Only opsfull can change operations + //opsfull can view operations + roles.Add(AyaType.AyaNova7Import, new BizRoleSet() + { + Change = AuthorizationRoles.OpsAdminFull, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.OpsAdminFull + }); + + + //////////////////////////////////////////////////////////// + //METRICS + // + roles.Add(AyaType.Metrics, new BizRoleSet() + { + Change = AuthorizationRoles.NoRole, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.OpsAdminFull | AuthorizationRoles.OpsAdminLimited + }); + + + //////////////////////////////////////////////////////////// + //LOCALE + // + roles.Add(AyaType.Locale, new BizRoleSet() + { + Change = AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminFull, + EditOwn = AuthorizationRoles.NoRole, + Read = AuthorizationRoles.AnyRole + }); + + + + + + //////////////////////////////////////////////////////////////////// + #endregion all roles init + + + + }//end of constructor + + + /// + /// Get roleset for biz object + /// + /// + /// + internal static BizRoleSet GetRoleSet(AyaType forType) + { + if (roles.ContainsKey(forType)) + { + return roles[forType]; + } + else + { + return null; + } + } + + + }//end of class + + +}//eons + diff --git a/server/AyaNova/biz/IBizObject.cs b/server/AyaNova/biz/IBizObject.cs new file mode 100644 index 00000000..cdc123d0 --- /dev/null +++ b/server/AyaNova/biz/IBizObject.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using AyaNova.Biz; +using AyaNova.Models; + +namespace AyaNova.Biz +{ + /// + /// + /// + internal interface IBizObject + { + //validate via validation attributes + //https://stackoverflow.com/questions/36330981/is-the-validationresult-class-suitable-when-validating-the-state-of-an-object + + + + /// + /// Roles of current user + /// + /// + AuthorizationRoles CurrentUserRoles { get; set; } + + + /// + /// Contains list of errors + /// + List Errors { get; } + + + /// + /// Is true if there are errors + /// + bool HasErrors { get; } + + /// + /// Is true if the field specified exists in the list + /// + bool PropertyHasErrors(string propertyName); + + + /// + /// + /// + /// + /// + /// + void AddError(ValidationErrorType errorType, string propertyName=null, string errorMessage=null); + + /// + /// + /// + /// + void AddvalidationError(ValidationError validationError); + + + + + } + +} \ No newline at end of file diff --git a/server/AyaNova/biz/IImportAyaNova7Object.cs b/server/AyaNova/biz/IImportAyaNova7Object.cs new file mode 100644 index 00000000..0c2e9174 --- /dev/null +++ b/server/AyaNova/biz/IImportAyaNova7Object.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AyaNova.Models; +using Newtonsoft.Json.Linq; + + +namespace AyaNova.Biz +{ + /// + /// Interface for biz objects that support importing AyaNova 7 data + /// + internal interface IImportAyaNova7Object + { + + /// + /// Import from the JSON data provided + /// + /// Json object containing source record + /// A collection that can be used to match import records to new records, NOT persistent between imports + /// JobId for logging or controlling jobs from within processor + /// True if imported, False if not imported due to invalid or other error (logged in job log) + Task ImportV7Async(JObject v7ImportData, List importMap, Guid JobId); + + + } + +} \ No newline at end of file diff --git a/server/AyaNova/biz/IJobObject.cs b/server/AyaNova/biz/IJobObject.cs new file mode 100644 index 00000000..81d5a428 --- /dev/null +++ b/server/AyaNova/biz/IJobObject.cs @@ -0,0 +1,22 @@ +using AyaNova.Models; + +namespace AyaNova.Biz +{ + /// + /// Interface for biz objects that support jobs / long running operations + /// + internal interface IJobObject + { + + /// + /// Start and process an operation + /// NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so + /// basically any error condition during job processing should throw up an exception if it can't be handled + /// + /// + System.Threading.Tasks.Task HandleJobAsync(OpsJob job); + + + } + +} \ No newline at end of file diff --git a/server/AyaNova/biz/ImportAyaNova7Biz.cs b/server/AyaNova/biz/ImportAyaNova7Biz.cs new file mode 100644 index 00000000..7786cce4 --- /dev/null +++ b/server/AyaNova/biz/ImportAyaNova7Biz.cs @@ -0,0 +1,163 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; + + +namespace AyaNova.Biz +{ + + + internal class ImportAyaNova7Biz : BizObject, IJobObject + { + private readonly AyContext ct; + private readonly long userId; + private readonly AuthorizationRoles userRoles; + + + internal ImportAyaNova7Biz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //JOB / OPERATIONS + // + public async Task HandleJobAsync(OpsJob job) + { + //Hand off the particular job to the corresponding processing code + //NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so + //basically any error condition during job processing should throw up an exception if it can't be handled + + //There might be future other job types so doing it like this for all biz job handlers for now + switch (job.JobType) + { + case JobType.ImportV7Data: + await ProcessImportV7JobAsync(job); + break; + default: + throw new System.ArgumentOutOfRangeException($"ImportAyaNovaBiz.HandleJob-> Invalid job type{job.JobType.ToString()}"); + } + } + + + /// + /// /// Handle the test job + /// + /// + private async Task ProcessImportV7JobAsync(OpsJob job) + { + //NOTE: If this code throws an exception the caller will automatically set the job to failed and log the exeption so + //basically any error condition during job processing should throw up an exception if it can't be handled + List importMap = new List(); + + JobsBiz.UpdateJobStatus(job.GId, JobStatus.Running, ct); + JobsBiz.LogJob(job.GId, $"ImportAyaNova7 starting", ct); + + //Get the import filename from the jsondata + JObject jobData = JObject.Parse(job.JobInfo); + var importFileName = jobData["ImportFileName"].Value(); + if (string.IsNullOrWhiteSpace(importFileName)) + { + throw new System.ArgumentNullException("ImportAyaNova7 job failed due to no import filename being specified"); + } + + if (!FileUtil.UtilityFileExists(importFileName)) + { + throw new System.ArgumentNullException("ImportAyaNova7 job failed due to import file specified not existing"); + } + + //get the contents of the archive + List zipEntries = FileUtil.ZipGetUtilityFileEntries(importFileName); + + //Iterate through the import items in the preferred order, checking for corresponding entries in the zip file + //In turn try to instantiate the type and id job that can handle that import, attempt to case or see if implements IImportAyaNova7Object + //, if null / not supported for import then skip and log as not currently supported + + //Pass off the JSON data from the import file into the import job item by item + + //IMPORT REGIONS AS TAGS + await DoImport("GZTW.AyaNova.BLL.Region", AyaType.Tag, job.GId, importMap, importFileName, zipEntries); + + //IMPORT LOCALES + await DoImport("GZTW.AyaNova.BLL.Locale", AyaType.Locale, job.GId, importMap, importFileName, zipEntries); + + JobsBiz.LogJob(job.GId, "ImportAyaNova7 finished", ct); + JobsBiz.UpdateJobStatus(job.GId, JobStatus.Completed, ct); + + + } + + /// + /// This method does the actual import by + /// - Fetching the list of entries in the zip archive that match the passed in startsWtih (folder name in zip archive) + /// - Instantiating the corresponding new biz object type to handle the import + /// - Passing the json parsed to the biz object one at a time to do the import + /// + /// + /// + /// + /// + /// + /// + /// + private async Task DoImport(string entryStartsWith, AyaType importerType, Guid jobId, List importMap, string importFileName, List zipEntries) + { + var zipObjectList = zipEntries.Where(m => m.StartsWith(entryStartsWith)).ToList(); + long importCount = 0; + long notImportCount = 0; + if (zipObjectList.Count > 0) + { + JobsBiz.LogJob(jobId, $"Starting import of {entryStartsWith} objects", ct); + var jList = FileUtil.ZipGetUtilityArchiveEntriesAsJsonObjects(zipObjectList, importFileName); + + IImportAyaNova7Object o = (IImportAyaNova7Object)BizObjectFactory.GetBizObject(importerType, ct); + foreach (JObject j in jList) + { + bool bImportSucceeded = false; + //some new types can import multiple old types and it might matter which is which to the importer + //so tag it with the original type + j.Add("V7_TYPE", JToken.FromObject(entryStartsWith)); + + bImportSucceeded = await o.ImportV7Async(j, importMap, jobId); + if (bImportSucceeded) + importCount++; + else + notImportCount++; + } + + if (importCount > 0) + { + JobsBiz.LogJob(jobId, $"Successfully imported {importCount.ToString()} of {zipObjectList.Count.ToString()} {entryStartsWith} objects", ct); + } + + if (notImportCount > 0) + { + JobsBiz.LogJob(jobId, $"Did not import {notImportCount.ToString()} of {zipObjectList.Count.ToString()} {entryStartsWith} objects", ct); + } + + + } + } + + //Other job handlers here... + + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/biz/JobOperationsBiz.cs b/server/AyaNova/biz/JobOperationsBiz.cs new file mode 100644 index 00000000..b1e79283 --- /dev/null +++ b/server/AyaNova/biz/JobOperationsBiz.cs @@ -0,0 +1,109 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; + + +namespace AyaNova.Biz +{ + + + internal class JobOperationsBiz : BizObject + { + private readonly AyContext ct; + private readonly long userId; + private readonly AuthorizationRoles userRoles; + + + internal JobOperationsBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + } + + + + #region CONTROLLER ROUTES SUPPORT + + //////////////////////////////////////////////////////////////////////////////////////////////// + /// GET + + //Get list of jobs + internal async Task> GetJobListAsync() + { + List ret = new List(); + + var jobitems = await ct.OpsJob + .OrderBy(m => m.Created) + .ToListAsync(); + + foreach (OpsJob i in jobitems) + { + //fetch the most recent log time for each job + var mostRecentLogItem = await ct.OpsJobLog + .Where(c => c.JobId == i.GId) + .OrderByDescending(t => t.Created) + .FirstOrDefaultAsync(); + + JobOperationsFetchInfo o = new JobOperationsFetchInfo(); + if (mostRecentLogItem != null) + o.LastAction = mostRecentLogItem.Created; + else + o.LastAction = i.Created; + + o.Created = i.Created; + o.GId = i.GId; + o.JobStatus = i.JobStatus.ToString(); + o.Name = i.Name; + ret.Add(o); + } + + return ret; + } + + + + //Get list of logs for job + internal async Task> GetJobLogListAsync(Guid jobId) + { + List ret = new List(); + + var l = await ct.OpsJobLog + .Where(c => c.JobId == jobId) + .OrderBy(m => m.Created) + .ToListAsync(); + + foreach (OpsJobLog i in l) + { + + JobOperationsLogInfoItem o = new JobOperationsLogInfoItem(); + + o.Created = i.Created; + o.StatusText = i.StatusText; + ret.Add(o); + } + + return ret; + } + + #endregion controller routes + + + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/biz/JobStatus.cs b/server/AyaNova/biz/JobStatus.cs new file mode 100644 index 00000000..5efbd771 --- /dev/null +++ b/server/AyaNova/biz/JobStatus.cs @@ -0,0 +1,17 @@ +namespace AyaNova.Biz +{ + + + /// + /// Job status for opsjobs + /// + public enum JobStatus : int + { + Sleeping = 1, + Running = 2, + Completed = 3, + Failed = 4 + } + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/biz/JobType.cs b/server/AyaNova/biz/JobType.cs new file mode 100644 index 00000000..441ab1af --- /dev/null +++ b/server/AyaNova/biz/JobType.cs @@ -0,0 +1,20 @@ +namespace AyaNova.Biz +{ + + + /// + /// All AyaNova Job types, used by OpsJob and biz objects for long running applications + /// + public enum JobType : int + { + NotSet = 0, + TestWidgetJob = 1,//test job for unit testing + CoreJobSweeper = 2, + ImportV7Data = 3, + SeedTestData = 4, + + + } + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/biz/JobsBiz.cs b/server/AyaNova/biz/JobsBiz.cs new file mode 100644 index 00000000..e38def8b --- /dev/null +++ b/server/AyaNova/biz/JobsBiz.cs @@ -0,0 +1,382 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using AyaNova.Util; + + +namespace AyaNova.Biz +{ + + + internal static class JobsBiz + { + private static ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger("JobsBiz"); + + #region JOB OPS + + /// + /// Get a non tracked list of jobs for an object + /// + /// + /// + /// + internal static async Task> GetJobsForObjectAsync(AyaTypeId ayObj, AyContext ct) + { + return await ct.OpsJob + .AsNoTracking() + .Where(c => c.ObjectId == ayObj.ObjectId && c.ObjectType == ayObj.ObjectType) + .OrderBy(m => m.Created) + .ToListAsync(); + } + + + + /// + /// Get a non tracked list of jobs that are ready to process and exclusive only + /// + /// + internal static async Task> GetReadyJobsExclusiveOnlyAsync(AyContext ct) + { + return await GetReadyJobsAsync(true, ct); + } + + + /// + /// Get a non tracked list of jobs that are ready to process and exclusive only + /// + /// + internal static async Task> GetReadyJobsNotExlusiveOnlyAsync(AyContext ct) + { + return await GetReadyJobsAsync(false, ct); + } + + + /// + /// Get a non tracked list of jobs filtered by exclusivity + /// + /// + private static async Task> GetReadyJobsAsync(bool exclusiveOnly, AyContext ct) + { + var ret = await ct.OpsJob + .AsNoTracking() + .Where(c => c.StartAfter < System.DateTime.UtcNow && c.Exclusive == exclusiveOnly && c.JobStatus == JobStatus.Sleeping) + .OrderBy(m => m.Created) + .ToListAsync(); + + return ret; + } + + + /// + /// Get a non tracked list of all jobs that are not completed + /// could be running or sleeping + /// + /// + internal static async Task> GetAllSleepingOrRunningJobsAsync(AyContext ct) + { + var ret = await ct.OpsJob + .AsNoTracking() + .Where(c => c.JobStatus == JobStatus.Sleeping || c.JobStatus == JobStatus.Running) + .OrderBy(m => m.Created) + .ToListAsync(); + + return ret; + } + + + /// + /// Get a non tracked list of all jobs for a JobType + /// + /// + internal static async Task> GetAllJobsForJobTypeAsync(AyContext ct, JobType jobType) + { + var ret = await ct.OpsJob + .AsNoTracking() + .Where(c => c.JobType == jobType) + .OrderBy(m => m.Created) + .ToListAsync(); + + return ret; + } + + + /// + /// Get a non tracked list of all jobs that are status running but have no last activity for XX HOURS + /// + /// + internal static async Task> GetPotentiallyDeadRunningJobsAsync(AyContext ct) + { + var ret = await ct.OpsJob + .AsNoTracking() + .Where(c => c.JobStatus == JobStatus.Sleeping || c.JobStatus == JobStatus.Running) + .OrderBy(m => m.Created) + .ToListAsync(); + + return ret; + } + + + /// + /// Get a count of all jobs for a JobStatus + /// + /// + internal static async Task GetCountForJobStatusAsync(AyContext ct, JobStatus jobStatus) + { + var ret = await ct.OpsJob + .Where(c => c.JobStatus == jobStatus) + .LongCountAsync(); + return ret; + } + + + + /// + /// Add a new job to the database + /// + /// + /// + /// + internal static OpsJob AddJob(OpsJob newJob, AyContext ct) + { + ct.OpsJob.Add(newJob); + ct.SaveChanges(); + return newJob; + } + + /// + /// Remove any jobs or logs for the object in question + /// + /// + /// + internal static async Task DeleteJobsForObjectAsync(AyaTypeId ayObj, AyContext ct) + { + //Get a list of all jobid's for the object passed in + List jobsForObject = GetJobsForObjectAsync(ayObj, ct).Result; + + //short circuit + if (jobsForObject.Count == 0) + return; + + using (var transaction = ct.Database.BeginTransaction()) + { + try + { + foreach (OpsJob jobToBeDeleted in jobsForObject) + { + await removeJobAndLogsAsync(ct, jobToBeDeleted.GId); + } + + // Commit transaction if all commands succeed, transaction will auto-rollback + // when disposed if either commands fails + transaction.Commit(); + } + catch (Exception ex) + { + throw ex; + } + } + } + + + + + /// + /// Remove job and logs for that job + /// + /// + /// + internal static async Task DeleteJobAndLogAsync(Guid jobId, AyContext ct) + { + using (var transaction = ct.Database.BeginTransaction()) + { + try + { + await removeJobAndLogsAsync(ct, jobId); + // Commit transaction if all commands succeed, transaction will auto-rollback + // when disposed if either commands fails + transaction.Commit(); + } + catch (Exception ex) + { + throw ex; + } + } + } + + + + /// + /// REmove the job and it's logs + /// + /// + /// + private static async Task removeJobAndLogsAsync(AyContext ct, Guid jobIdToBeDeleted) + { + //delete logs + await ct.Database.ExecuteSqlCommandAsync("delete from aopsjoblog where jobid = {0}", new object[] { jobIdToBeDeleted }); + + //delete the job + await ct.Database.ExecuteSqlCommandAsync("delete from aopsjob where gid = {0}", new object[] { jobIdToBeDeleted }); + } + + + + + + + + /// + /// Make a log entry for a job + /// + /// + /// + /// + internal static OpsJobLog LogJob(Guid jobId, string statusText, AyContext ct) + { + if (string.IsNullOrWhiteSpace(statusText)) + statusText = "No status provided"; + OpsJobLog newObj = new OpsJobLog(); + newObj.JobId = jobId; + newObj.StatusText = statusText; + ct.OpsJobLog.Add(newObj); + ct.SaveChanges(); + return newObj; + } + + + /// + /// Update the status of a job + /// + /// + /// + /// + internal static OpsJob UpdateJobStatus(Guid jobId, JobStatus newStatus, AyContext ct) + { + var oFromDb = ct.OpsJob.SingleOrDefault(m => m.GId == jobId); + if (oFromDb == null) return null; + oFromDb.JobStatus = newStatus; + ct.SaveChanges(); + return oFromDb; + } + #endregion Job ops + + #region PROCESSOR + + /// + /// Process all jobs (stock jobs and those found in operations table) + /// + /// + internal static async Task ProcessJobsAsync(AyContext ct) + { + //Flush metrics report before anything else happens + log.LogTrace("Flushing metrics to reporters"); + await CoreJobMetricsReport.DoJobAsync(); + + + //BIZOBJECT DYNAMIC JOBS + //get a list of exclusive jobs that are due to happen + //Call into each item in turn + List exclusiveJobs = await GetReadyJobsExclusiveOnlyAsync(ct); + foreach (OpsJob j in exclusiveJobs) + { + try + { + await ProcessJobAsync(j, ct); + } + catch (Exception ex) + { + log.LogError(ex, $"ProcessJobs::Exclusive -> job {j.Name} failed with exception"); + LogJob(j.GId, "Job failed with errors:", ct); + LogJob(j.GId, ExceptionUtil.ExtractAllExceptionMessages(ex), ct); + UpdateJobStatus(j.GId, JobStatus.Failed, ct); + } + } + //Get a list of non-exlusive jobs that are due + + //TODO: Parallelize / background this block + //http://www.dotnetcurry.com/dotnet/1360/concurrent-programming-dotnet-core + + //var backgroundTask = Task.Run(() => DoComplexCalculation(42)); + //also have to deal with db object etc, I guess they'd have to instantiate themselves to avoid disposed object being used error + //This area may turn out to need a re-write in future, but I think it might only involve this block and ProcessJobAsync + //the actual individual objects that are responsible for jobs will likely not need a signature rewrite or anything (I hope) + //For now I'm hoping that no job will be so slow that it can hold up all the other jobs indefinitely. + + List sharedJobs = await GetReadyJobsNotExlusiveOnlyAsync(ct); + foreach (OpsJob j in sharedJobs) + { + try + { + await ProcessJobAsync(j, ct); + } + catch (Exception ex) + { + log.LogError(ex, $"ProcessJobs::Shared -> job {j.Name} failed with exception"); + LogJob(j.GId, "Job failed with errors:", ct); + LogJob(j.GId, ExceptionUtil.ExtractAllExceptionMessages(ex), ct); + UpdateJobStatus(j.GId, JobStatus.Failed, ct); + } + } + + + + //STOCK JOBS + + //Sweep jobs table + await CoreJobSweeper.DoSweepAsync(ct); + + //Health check / metrics + await CoreJobMetricsSnapshot.DoJobAsync(ct); + + //License check?? + + //Notifications + + + } + + /// + /// Process a job by calling into it's biz object + /// + /// + /// + /// + internal static async Task ProcessJobAsync(OpsJob job, AyContext ct) + { + + log.LogDebug($"ProcessJobAsync -> Processing job {job.Name} (type {job.JobType.ToString()})"); + IJobObject o = null; + + switch (job.JobType) + { + case JobType.TestWidgetJob: + o = (IJobObject)BizObjectFactory.GetBizObject(AyaType.Widget, ct); + break; + case JobType.ImportV7Data: + o = (IJobObject)BizObjectFactory.GetBizObject(AyaType.AyaNova7Import, ct); + break; + case JobType.SeedTestData: + o = (IJobObject)BizObjectFactory.GetBizObject(AyaType.TrialSeeder, ct); + break; + default: + throw new System.NotSupportedException($"ProcessJobAsync type {job.JobType.ToString()} is not supported"); + } + + await o.HandleJobAsync(job); + log.LogDebug($"ProcessJobAsync -> Job completed {job.Name} (type {job.JobType.ToString()})"); + } + + + #endregion process jobs + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/biz/LocaleBiz.cs b/server/AyaNova/biz/LocaleBiz.cs new file mode 100644 index 00000000..49ed83d5 --- /dev/null +++ b/server/AyaNova/biz/LocaleBiz.cs @@ -0,0 +1,456 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Models; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +using Microsoft.Extensions.DependencyInjection; + +namespace AyaNova.Biz +{ + + internal class LocaleBiz : BizObject, IImportAyaNova7Object + { + private readonly AyContext ct; + private readonly long userId; + private readonly AuthorizationRoles userRoles; + + + internal LocaleBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DUPLICATE - only way to create a new locale + // + internal async Task DuplicateAsync(NameIdItem inObj) + { + + //make sure sourceid exists + if (!LocaleExists(inObj.Id)) + AddError(ValidationErrorType.InvalidValue, "Id", "Source locale id does not exist"); + + //Ensure name is unique and not too long and not empty + Validate(inObj.Name, true); + + if (HasErrors) + return null; + + //fetch the existing locale for duplication + var SourceLocale = await ct.Locale.Include(x => x.LocaleItems).SingleOrDefaultAsync(m => m.Id == inObj.Id); + + //replicate the source to a new dest and save + Locale NewLocale = new Locale(); + NewLocale.Name = inObj.Name; + NewLocale.OwnerId = this.userId; + NewLocale.Stock = false; + foreach (LocaleItem i in SourceLocale.LocaleItems) + { + NewLocale.LocaleItems.Add(new LocaleItem() { Key = i.Key, Display = i.Display }); + } + + //Add it to the context so the controller can save it + ct.Locale.Add(NewLocale); + return NewLocale; + + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + /// GET + + //Get entire locale + internal async Task GetAsync(long fetchId) + { + //This is simple so nothing more here, but often will be copying to a different output object or some other ops + return await ct.Locale.Include(x => x.LocaleItems).SingleOrDefaultAsync(m => m.Id == fetchId); + } + + + + //get picklist (simple non-paged) + internal async Task> GetPickListAsync() + { + List l = new List(); + l = await ct.Locale + .OrderBy(m => m.Name) + .Select(m => new NameIdItem() + { + Id = m.Id, + Name = m.Name + }).ToListAsync(); + + return l; + + } + + + //Get the keys for a list of keys provided + internal async Task>> GetSubset(AyaNova.Api.Controllers.LocaleController.LocaleSubsetParam param) + { + var ret = await ct.LocaleItem.Where(x => x.LocaleId == param.LocaleId && param.Keys.Contains(x.Key)).ToDictionaryAsync(x => x.Key, x => x.Display); + return ret.ToList(); + } + + + /// + /// Get the value of the key provided in the default locale chosen + /// + /// + /// + internal static async Task GetDefaultLocalizedText(string key) + { + if (string.IsNullOrWhiteSpace(key)) + return "ERROR: GetDefaultLocalizedText NO KEY VALUE SPECIFIED"; + AyContext ct = ServiceProviderProvider.DBContext; + return await ct.LocaleItem.Where(m => m.LocaleId == ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE_ID && m.Key == key).Select(m => m.Display).FirstOrDefaultAsync(); + } + + //Get all stock keys that are valid (used for import) + internal static List GetKeyList() + { + AyContext ct = ServiceProviderProvider.DBContext; + return ct.LocaleItem.Where(m => m.LocaleId == 1).OrderBy(m => m.Key).Select(m => m.Key).ToList(); + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + + + internal bool PutLocaleItemDisplayText(LocaleItem dbObj, NewTextIdConcurrencyTokenItem inObj, Locale dbParent) + { + + if (dbParent.Stock == true) + { + AddError(ValidationErrorType.InvalidOperation, "object", "LocaleItem is from a Stock locale and cannot be modified"); + return false; + } + + //Replace the db object with the PUT object + //CopyObject.Copy(inObj, dbObj, "Id"); + dbObj.Display = inObj.NewText; + //Set "original" value of concurrency token to input token + //this will allow EF to check it out + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = inObj.ConcurrencyToken; + + //Only thing to validate is if it has data at all in it + if (string.IsNullOrWhiteSpace(inObj.NewText)) + AddError(ValidationErrorType.RequiredPropertyEmpty, "Display (NewText)"); + + if (HasErrors) + return false; + + return true; + } + + + internal bool PutLocaleName(Locale dbObj, NewTextIdConcurrencyTokenItem inObj) + { + if (dbObj.Stock == true) + { + AddError(ValidationErrorType.InvalidOperation, "object", "Locale is a Stock locale and cannot be modified"); + return false; + } + + dbObj.Name = inObj.NewText; + + //Set "original" value of concurrency token to input token + //this will allow EF to check it out + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = inObj.ConcurrencyToken; + + Validate(dbObj.Name, false); + + if (HasErrors) + return false; + + return true; + } + + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + + internal bool Delete(Locale dbObj) + { + //Determine if the object can be deleted, do the deletion tentatively + ValidateCanDelete(dbObj); + if (HasErrors) + return false; + ct.Locale.Remove(dbObj); + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + + //Can save or update? + private void Validate(string inObjName, bool isNew) + { + //run validation and biz rules + + //Name required + if (string.IsNullOrWhiteSpace(inObjName)) + AddError(ValidationErrorType.RequiredPropertyEmpty, "Name"); + + //Name must be less than 255 characters + if (inObjName.Length > 255) + AddError(ValidationErrorType.LengthExceeded, "Name", "255 char max"); + + //Name must be unique + if (ct.Locale.Where(m => m.Name == inObjName).FirstOrDefault() != null) + AddError(ValidationErrorType.NotUnique, "Name"); + + return; + } + + + //Can delete? + private void ValidateCanDelete(Locale inObj) + { + //Decided to short circuit these; if there is one issue then return immediately (fail fast rule) + + //Ensure it's not a stock locale + if (inObj.Stock == true) + { + AddError(ValidationErrorType.InvalidOperation, "object", "Locale is a Stock locale and cannot be deleted"); + return; + } + + if (inObj.Id == ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE_ID) + { + AddError(ValidationErrorType.InvalidOperation, "object", "Locale is set as the default server locale (AYANOVA_DEFAULT_LANGUAGE_ID) and can not be deleted"); + return; + } + + //See if any users exist with this locale selected in which case it's not deleteable + if (ct.User.Any(e => e.LocaleId == inObj.Id)) + { + AddError(ValidationErrorType.ReferentialIntegrity, "object", "Can't be deleted in use by one or more Users"); + return; + } + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UTILITIES + // + + public long LocaleNameToId(string localeName) + { + var v = ct.Locale.Where(c => c.Name == localeName).Select(x => x.Id); + if (v.Count() < 1) return 0; + return v.First(); + } + + + public bool LocaleExists(string localeName) + { + return LocaleNameToId(localeName) != 0; + } + + public bool LocaleExists(long id) + { + return ct.Locale.Any(e => e.Id == id); + } + + public bool LocaleItemExists(long id) + { + return ct.LocaleItem.Any(e => e.Id == id); + } + + + /// + /// Used by import, translate the old v7 locale key name into the new shorter version + /// + /// + /// + public string Translatev7LocaleKey(string oldKey) + { + string s = oldKey.Replace(".Label.", ".", StringComparison.InvariantCultureIgnoreCase); + if (s.StartsWith("O.", StringComparison.InvariantCultureIgnoreCase)) + s = s.Replace("O.", "", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace(".ToolBar.", ".", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace(".Go.", ".", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace(".Command.", ".", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace(".Error.", ".", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace(".Object.", ".", StringComparison.InvariantCultureIgnoreCase); + if (s.StartsWith("UI.", StringComparison.InvariantCultureIgnoreCase)) + s = s.Replace("UI.", "", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace(".", "", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace("AddressAddress", "Address", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace("ContactPhoneContactPhone", "ContactPhone", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace("ContactPhonePhone", "ContactPhone", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace("PurchaseOrderPurchaseOrder", "PurchaseOrder", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace("WorkorderItemMiscExpenseExpense", "WorkorderItemMiscExpense", StringComparison.InvariantCultureIgnoreCase); + s = s.Replace("WorkorderItemTravelTravel", "WorkorderItemTravel", StringComparison.InvariantCultureIgnoreCase); + return s; + } + + //ocaleBiz::ImportV7 - old Key "Locale.Label.UI.DestLocale" translates to new Key "LocaleDestLocale" which is not valid! + //LocaleUIDestLocale + + + /// + /// Ensure stock locales and setup defaults + /// Called by boot preflight check code AFTER it has already ensured the locale is a two letter code if stock one was chosen + /// + public void ValidateLocales() + { + //Ensure default locales are present and that there is a server default locale that exists + + if (!LocaleExists("en")) + { + throw new System.Exception($"E1015: stock locale English (en) not found in database!"); + } + if (!LocaleExists("es")) + { + throw new System.Exception($"E1015: stock locale Spanish (es) not found in database!"); + } + if (!LocaleExists("de")) + { + throw new System.Exception($"E1015: stock locale German (de) not found in database!"); + } + if (!LocaleExists("fr")) + { + throw new System.Exception($"E1015: stock locale French (fr) not found in database!"); + } + + //Ensure chosen default locale exists + switch (ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE) + { + case "en": + case "es": + case "de": + case "fr": + break; + default: + if (!LocaleExists(ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE)) + { + throw new System.Exception($"E1015: stock locale French (fr) not found in database!"); + } + break; + + } + + //Put the default locale ID number into the ServerBootConfig for later use + ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE_ID = LocaleNameToId(ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE); + + } + + + ///////////////////////////////////////////////////////////////////// + /// IMPORT v7 implementation + public async Task ImportV7Async(JObject j, List importMap, Guid jobId) + { + //some types need to import from more than one source hence the seemingly redundant switch statement for futureproofing + switch (j["V7_TYPE"].Value()) + { + case "GZTW.AyaNova.BLL.Locale": + { + //Get source locale name from filename using regex + var SourceLocaleFileName = j["V7_SOURCE_FILE_NAME"].Value(); + Regex RxExtractLocaleName = new Regex(@"locale\.(.*)\.json"); + var v = RxExtractLocaleName.Match(SourceLocaleFileName); + var SourceLocaleName = v.Groups[1].ToString(); + + //ENsure doesn't already exist + if (LocaleExists(SourceLocaleName)) + { + //If there are any validation errors, log in joblog and move on + JobsBiz.LogJob(jobId, $"LocaleBiz::ImportV7Async -> - Locale \"{SourceLocaleName}\" already exists in database, can not import over an existing locale", ct); + return false; + } + + //keys to skip importing + List SkipKeys = new List(); + SkipKeys.Add("UI.Label.CurrentUserName"); + SkipKeys.Add("V7_SOURCE_FILE_NAME"); + SkipKeys.Add("V7_TYPE"); + + List ValidKeys = GetKeyList(); + Dictionary NewLocaleDict = new Dictionary(); + foreach (var Pair in j.Children()) + { + var V7Value = Pair.First.Value(); + var V7KeyName = ((JProperty)Pair).Name; + + if (!SkipKeys.Contains(V7KeyName)) + { + var RavenKeyName = Translatev7LocaleKey(V7KeyName); + if (!ValidKeys.Contains(RavenKeyName)) + { + throw new System.ArgumentOutOfRangeException($"LocaleBiz::ImportV7 - old Key \"{V7KeyName}\" translates to new Key \"{RavenKeyName}\" which is not valid!"); + } + + if (!NewLocaleDict.ContainsKey(RavenKeyName)) + { + NewLocaleDict.Add(RavenKeyName, V7Value); + } + else + { + //Use the shortest V7Value string in the case of dupes + if (NewLocaleDict[RavenKeyName].Length > V7Value.Length) + { + NewLocaleDict[RavenKeyName] = V7Value; + } + } + } + } + + //Validate it's the correct number of keys expected + if (NewLocaleDict.Count != ValidKeys.Count) + { + throw new System.ArgumentOutOfRangeException($"LocaleBiz::ImportV7 - Import locale \"{SourceLocaleName}\" has an unexpected number of keys: {NewLocaleDict.Count}, expected {ValidKeys.Count} "); + } + + //have file name, have all localized text + Locale l = new Locale(); + l.Name = SourceLocaleName; + l.OwnerId = 1; + l.Stock = false; + + foreach (KeyValuePair K in NewLocaleDict) + { + l.LocaleItems.Add(new LocaleItem() { Key = K.Key, Display = K.Value }); + } + + ct.Locale.Add(l); + ct.SaveChanges(); + + + } + break; + } + + //just to hide compiler warning for now + await Task.CompletedTask; + //this is the equivalent of returning void for a Task signature with nothing to return + return true; + } + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + // +}//eons + diff --git a/server/AyaNova/biz/PrimeData.cs b/server/AyaNova/biz/PrimeData.cs new file mode 100644 index 00000000..147b00f4 --- /dev/null +++ b/server/AyaNova/biz/PrimeData.cs @@ -0,0 +1,105 @@ +using System; +using System.IO; +using Newtonsoft.Json.Linq; +using Microsoft.Extensions.Logging; +using AyaNova.Util; +using AyaNova.Models; + + +namespace AyaNova.Biz +{ + + public static class PrimeData + { + // private readonly AyContext ct; + // private readonly ILogger log; + + // public PrimeData(AyContext dbcontext, ILogger logger) + // { + // ct = dbcontext; + // log = logger; + // } + + /// + /// Prime the database + /// + public static void PrimeManagerAccount(AyContext ct) + { + //get a db and logger + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger("PrimeData"); + User u = new User(); + u.Name = "AyaNova Administrator"; + u.Salt = Hasher.GenerateSalt(); + u.Login = "manager"; + u.Password = Hasher.hash(u.Salt, "l3tm3in"); + u.Roles = AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminFull | AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryFull; + u.OwnerId = 1; + u.LocaleId=ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE_ID;//Ensure primeLocales is called first + ct.User.Add(u); + ct.SaveChanges(); + } + + + /// + /// Prime the locales + /// This may be called before there are any users on a fresh db boot + /// + public static void PrimeLocales(AyContext ct) + { + + //Read in each stock locale from a text file and then create them in the DB + var ResourceFolderPath = Path.Combine(ServerBootConfig.AYANOVA_CONTENT_ROOT_PATH, "resource"); + if (!Directory.Exists(ResourceFolderPath)) + { + throw new System.Exception($"E1012: \"resource\" folder not found where expected: \"{ResourceFolderPath}\", installation damaged?"); + } + + + ImportLocale(ct, ResourceFolderPath, "en"); + ImportLocale(ct, ResourceFolderPath, "es"); + ImportLocale(ct, ResourceFolderPath, "fr"); + ImportLocale(ct, ResourceFolderPath, "de"); + + //Ensure locales are present, not missing any keys and that there is a server default locale that exists + LocaleBiz lb = new LocaleBiz(ct, 1, AuthorizationRoles.OpsAdminFull); + lb.ValidateLocales(); + + } + + private static void ImportLocale(AyContext ct, string resourceFolderPath, string localeCode) + { + var LocalePath = Path.Combine(resourceFolderPath, $"{localeCode}.json"); + if (!File.Exists(LocalePath)) + { + throw new System.Exception($"E1013: stock locale file \"{localeCode}\" not found where expected: \"{LocalePath}\", installation damaged?"); + } + + JObject o = JObject.Parse(File.ReadAllText(LocalePath)); + + Locale l = new Locale(); + l.Name = localeCode; + l.OwnerId = 1; + l.Stock = true; + + foreach (JToken t in o.Children()) + { + var key = t.Path; + var display = t.First.Value(); + l.LocaleItems.Add(new LocaleItem() { Key = key, Display = display });//, Locale = l + } + + ct.Locale.Add(l); + ct.SaveChanges(); + } + + + + + + + + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/biz/TagBiz.cs b/server/AyaNova/biz/TagBiz.cs new file mode 100644 index 00000000..69994ca1 --- /dev/null +++ b/server/AyaNova/biz/TagBiz.cs @@ -0,0 +1,275 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; +using Newtonsoft.Json.Linq; +using System.Collections.Generic; + +namespace AyaNova.Biz +{ + + internal class TagBiz : BizObject, IImportAyaNova7Object + { + private readonly AyContext ct; + private readonly long userId; + private readonly AuthorizationRoles userRoles; + + + internal TagBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + } + + + /* + TODO: add methods here to deal with various tag operations in the db + */ + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + internal async Task CreateAsync(string inObj) + { + //Must be lowercase per rules + //This may be naive when we get international customers but for now supporting utf-8 and it appears it's safe to do this with unicode + inObj = inObj.ToLowerInvariant(); + + //No spaces in tags, replace with dashes + inObj = inObj.Replace(" ", "-"); + + Validate(inObj, true); + if (HasErrors) + return null; + else + { + //do stuff with Tag + Tag outObj = new Tag() + { + Name = inObj, + OwnerId = userId, + Created = System.DateTime.UtcNow + }; + + + await ct.Tag.AddAsync(outObj); + return outObj; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + /// GET + + //Get one + internal async Task GetAsync(long fetchId) + { + //This is simple so nothing more here, but often will be copying to a different output object or some other ops + return await ct.Tag.SingleOrDefaultAsync(m => m.Id == fetchId); + } + + + + //get picklist (paged) + //Unlike most picklists, this one only checks for starts with and wildcards are not supported / treated as part of tag name + internal async Task> GetPickListAsync(IUrlHelper Url, string routeName, PagingOptions pagingOptions, string q) + { + pagingOptions.Offset = pagingOptions.Offset ?? PagingOptions.DefaultOffset; + pagingOptions.Limit = pagingOptions.Limit ?? PagingOptions.DefaultLimit; + + NameIdItem[] items; + int totalRecordCount = 0; + + if (!string.IsNullOrWhiteSpace(q)) + { + //tags are allow saved this way so search this way too + q = q.ToLowerInvariant(); + + items = await ct.Tag + //There is some debate on this for efficiency + //I chose this method because I think it escapes potential wildcards in the string automatically + //and I don't want people using wildcards with this, only starts with is supported + //https://stackoverflow.com/questions/45708715/entity-framework-ef-functions-like-vs-string-contains + .Where(m => m.Name.StartsWith(q)) + // .Where(m => EF.Functions.ILike(m.Name, q)) + .OrderBy(m => m.Name) + .Skip(pagingOptions.Offset.Value) + .Take(pagingOptions.Limit.Value) + .Select(m => new NameIdItem() + { + Id = m.Id, + Name = m.Name + }).ToArrayAsync(); + + totalRecordCount = await ct.Tag.Where(m => m.Name.StartsWith(q)).CountAsync(); + } + else + { + items = await ct.Tag + .OrderBy(m => m.Name) + .Skip(pagingOptions.Offset.Value) + .Take(pagingOptions.Limit.Value) + .Select(m => new NameIdItem() + { + Id = m.Id, + Name = m.Name + }).ToArrayAsync(); + + totalRecordCount = await ct.Tag.CountAsync(); + } + + + + var pageLinks = new PaginationLinkBuilder(Url, routeName, null, pagingOptions, totalRecordCount).PagingLinksObject(); + + ApiPagedResponse pr = new ApiPagedResponse(items, pageLinks); + return pr; + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + + //put + internal bool Put(Tag dbObj, Tag inObj) + { + + //Replace the db object with the PUT object + CopyObject.Copy(inObj, dbObj, "Id"); + //Set "original" value of concurrency token to input token + //this will allow EF to check it out + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = inObj.ConcurrencyToken; + + //Must be lowercase per rules + //This may be naive when we get international customers but for now supporting utf-8 and it appears it's safe to do this with unicode + dbObj.Name = dbObj.Name.ToLowerInvariant(); + + Validate(dbObj.Name, false); + if (HasErrors) + return false; + + return true; + } + + //patch + internal bool Patch(Tag dbObj, JsonPatchDocument objectPatch, uint concurrencyToken) + { + //Do the patching + objectPatch.ApplyTo(dbObj); + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = concurrencyToken; + dbObj.Name = dbObj.Name.ToLowerInvariant(); + Validate(dbObj.Name, false); + if (HasErrors) + return false; + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + + internal bool Delete(Tag dbObj) + { + //Determine if the object can be deleted, do the deletion tentatively + + ValidateCanDelete(dbObj); + if (HasErrors) + return false; + ct.Tag.Remove(dbObj); + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + + //Can save or update? + private void Validate(string inObj, bool isNew) + { + //run validation and biz rules + + //Name required + if (string.IsNullOrWhiteSpace(inObj)) + AddError(ValidationErrorType.RequiredPropertyEmpty, "Name"); + + //Name must be less than 35 characters + if (inObj.Length > 35) + AddError(ValidationErrorType.LengthExceeded, "Name", "35 char max"); + + //Name must be unique + if (ct.Tag.Where(m => m.Name == inObj).FirstOrDefault() != null) + AddError(ValidationErrorType.NotUnique, "Name"); + + return; + } + + + //Can delete? + private void ValidateCanDelete(Tag inObj) + { + //whatever needs to be check to delete this object + + //See if any tagmaps exist with this tag in which case it's not deleteable + if (ct.TagMap.Any(e => e.TagId == inObj.Id)) + { + AddError(ValidationErrorType.ReferentialIntegrity, "object", "Can't be deleted while has relations"); + } + + } + + + + + ///////////////////////////////////////////////////////////////////// + /// IMPORT v7 implementation + public async Task ImportV7Async(JObject j, List importMap, Guid jobId) + { + switch (j["V7_TYPE"].Value()) + { + case "GZTW.AyaNova.BLL.Region": + { + var name = j["Name"].Value(); + var oldId = new Guid(j["ID"].Value()); + + //In RAVEN tags can only be 35 characters + name = StringUtil.MaxLength(name, 35); + + Tag o = await CreateAsync(name); + if (HasErrors) + { + //If there are any validation errors, log in joblog and move on + JobsBiz.LogJob(jobId, $"TagBiz::ImportV7Async -> import object \"{name}\" source id {oldId.ToString()} failed validation and was not imported: {GetErrorsAsString()} ", ct); + return false; + } + else + { + await ct.SaveChangesAsync(); + var mapItem = new ImportAyaNova7MapItem(oldId, AyaType.Tag, o.Id); + } + + } + break; + } + + //this is the equivalent of returning void for a Task signature with nothing to return + return true; + } + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + +// +}//eons + diff --git a/server/AyaNova/biz/TagMapBiz.cs b/server/AyaNova/biz/TagMapBiz.cs new file mode 100644 index 00000000..dfe7cc1e --- /dev/null +++ b/server/AyaNova/biz/TagMapBiz.cs @@ -0,0 +1,180 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; +using System.Collections.Generic; + + +namespace AyaNova.Biz +{ + + + internal class TagMapBiz : BizObject + { + private readonly AyContext ct; + private readonly long userId; + private readonly AuthorizationRoles userRoles; + + + internal TagMapBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + } + + + /* + TODO: add methods here to deal with various tag operations in the db + */ + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + internal async Task CreateAsync(TagMapInfo inObj) + { + + Validate(inObj, true); + if (HasErrors) + return null; + else + { + //do stuff with TagMap + TagMap outObj = new TagMap() + { + TagId = inObj.TagId, + TagToObjectId = inObj.TagToObjectId, + TagToObjectType = inObj.TagToObjectType, + OwnerId = userId + }; + + + await ct.TagMap.AddAsync(outObj); + return outObj; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + /// GET + + //Get one + internal async Task GetAsync(long fetchId) + { + //This is simple so nothing more here, but often will be copying to a different output object or some other ops + return await ct.TagMap.SingleOrDefaultAsync(m => m.Id == fetchId); + } + + + internal async Task> GetTagsOnObjectListAsync(AyaTypeId tid) + { + /* + + NOTES: This will be a bit of a "hot" path as every fetch of any main object will involve this + for now, just make it work and later can improve performance + + Also is sort going to be adequate, it's supposed to be based on invariant culture + + */ + + List l = new List(); + + //Get the list of tags on the object + var tagmapsOnObject = await ct.TagMap + .Where(m => m.TagToObjectId == tid.ObjectId && m.TagToObjectType == tid.ObjectType) + .Select(m => m.TagId) + .ToListAsync(); + + foreach (long tagId in tagmapsOnObject) + { + var tagFromDb = await ct.Tag.SingleOrDefaultAsync(m => m.Id == tagId); + if (tagFromDb != null) + { + l.Add(new NameIdItem() { Id = tagFromDb.Id, Name = tagFromDb.Name }); + } + } + + //Return the list sorted alphabetically + //Note if this is commonly required then maybe make a helper / extension for it + return (l.OrderBy(o => o.Name).ToList()); + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + + internal bool Delete(TagMap dbObj) + { + //Determine if the object can be deleted, do the deletion tentatively + + ValidateCanDelete(dbObj); + if (HasErrors) + return false; + ct.TagMap.Remove(dbObj); + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE ALL TAGMAPS FOR OBJECT + // + + static internal bool DeleteAllForObject(AyaTypeId parentObj, AyContext ct) + { + //Be careful in future, if you put ToString at the end of each object in the string interpolation + //npgsql driver will assume it's a string and put quotes around it triggering an error that a string can't be compared to an int + ct.Database.ExecuteSqlCommand($"delete from atagmap where tagtoobjectid={parentObj.ObjectId} and tagtoobjecttype={parentObj.ObjectTypeAsInt}"); + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + + //Can save or update? + private void Validate(TagMapInfo inObj, bool isNew) + { + //run validation and biz rules + + // //Name required + // if (string.IsNullOrWhiteSpace(inObj)) + // AddError(ValidationErrorType.RequiredPropertyEmpty, "Name"); + + // //Name must be less than 35 characters + // if (inObj.Length > 35) + // AddError(ValidationErrorType.LengthExceeded, "Name", "35 char max"); + + return; + } + + + //Can delete? + private void ValidateCanDelete(TagMap inObj) + { + //whatever needs to be check to delete this object + + //See if any tagmaps exist with this tag in which case it's not deleteable + // if (ct.TagMap.Any(e => e.TagMapId == inObj.Id)) + // { + // AddError(ValidationErrorType.ReferentialIntegrity, "object", "Can't be deleted while has relations"); + // } + + } + + + ///////////////////////////////////////////////////////////////////// + + + + }//eoc + + +}//eons + diff --git a/server/AyaNova/biz/TaggableAttribute.cs b/server/AyaNova/biz/TaggableAttribute.cs new file mode 100644 index 00000000..c2803f7c --- /dev/null +++ b/server/AyaNova/biz/TaggableAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace AyaNova.Biz +{ + /// + /// Marker attribute indicating that an object supports tagging + /// Used in + /// + [AttributeUsage(AttributeTargets.All)] + public class TaggableAttribute : Attribute + { + //No code required, it's just a marker + //https://docs.microsoft.com/en-us/dotnet/standard/attributes/writing-custom-attributes + } +}//eons diff --git a/server/AyaNova/biz/TrialBiz.cs b/server/AyaNova/biz/TrialBiz.cs new file mode 100644 index 00000000..6556092e --- /dev/null +++ b/server/AyaNova/biz/TrialBiz.cs @@ -0,0 +1,96 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; + + +namespace AyaNova.Biz +{ + + + /// + /// Handle data seeding and other trial ops + /// + internal class TrialBiz : BizObject, IJobObject + { + private readonly AyContext ct; + private readonly long userId; + private readonly AuthorizationRoles userRoles; + // private readonly ApiServerState serverState; + + internal TrialBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + //serverState = apiServerState;, ApiServerState apiServerState + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //JOB / OPERATIONS + // + public async Task HandleJobAsync(OpsJob job) + { + //Hand off the particular job to the corresponding processing code + //NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so + //basically any error condition during job processing should throw up an exception if it can't be handled + + //There might be future other job types so doing it like this for all biz job handlers for now + switch (job.JobType) + { + case JobType.SeedTestData: + await ProcessSeedTestData(job); + // ProcessSeedTestData(job); + break; + default: + throw new System.ArgumentOutOfRangeException($"TrialBiz.HandleJobAsync -> Invalid job type{job.JobType.ToString()}"); + } + } + + + /// + /// Handle the job + /// + /// + private async Task ProcessSeedTestData(OpsJob job) + {// + //NOTE: If this code throws an exception the caller will automatically set the job to failed and log the exeption so + //basically any error condition during job processing should throw up an exception if it can't be handled + + + //FOR NOW NOT ASYNC so faking it at end of this method + + + JobsBiz.UpdateJobStatus(job.GId, JobStatus.Running, ct); + JobsBiz.LogJob(job.GId, $"Starting...", ct); + + //Get the import filename from the jsondata + JObject jobData = JObject.Parse(job.JobInfo); + var seedLevel = (Seeder.SeedLevel)jobData["seedLevel"].Value(); + Seeder.SeedDatabase(ct, seedLevel); + JobsBiz.LogJob(job.GId, "Finished.", ct); + JobsBiz.UpdateJobStatus(job.GId, JobStatus.Completed, ct); + await Task.CompletedTask; + } + + + + //Other job handlers here... + + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/biz/ValidationError.cs b/server/AyaNova/biz/ValidationError.cs new file mode 100644 index 00000000..bdae1154 --- /dev/null +++ b/server/AyaNova/biz/ValidationError.cs @@ -0,0 +1,12 @@ +namespace AyaNova.Biz +{ + + public class ValidationError + { + public ValidationErrorType ErrorType { get; set; } + public string Target { get; set; } + public string Message { get; set; } + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/biz/ValidationErrorType.cs b/server/AyaNova/biz/ValidationErrorType.cs new file mode 100644 index 00000000..ce809247 --- /dev/null +++ b/server/AyaNova/biz/ValidationErrorType.cs @@ -0,0 +1,22 @@ +namespace AyaNova.Biz +{ + + public enum ValidationErrorType + { + RequiredPropertyEmpty = 1, + LengthExceeded = 2, + NotUnique = 3, + StartDateMustComeBeforeEndDate = 4, + InvalidValue = 5, + ReferentialIntegrity = 6, + InvalidOperation = 7 + + //!! NOTE - UPDATE api-validation-error-codes.md documentation when adding items + + } + + +}//eons + + + diff --git a/server/AyaNova/biz/WidgetBiz.cs b/server/AyaNova/biz/WidgetBiz.cs new file mode 100644 index 00000000..f973f694 --- /dev/null +++ b/server/AyaNova/biz/WidgetBiz.cs @@ -0,0 +1,314 @@ +using System.Linq; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; + + +namespace AyaNova.Biz +{ + + + internal class WidgetBiz : BizObject, IJobObject + { + private readonly AyContext ct; + private readonly long userId; + private readonly AuthorizationRoles userRoles; + + + internal WidgetBiz(AyContext dbcontext, long currentUserId, AuthorizationRoles UserRoles) + { + ct = dbcontext; + userId = currentUserId; + userRoles = UserRoles; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //CREATE + internal async Task CreateAsync(Widget inObj) + { + Validate(inObj, true); + if (HasErrors) + return null; + else + { + //do stuff with widget + Widget outObj = inObj; + outObj.OwnerId = userId; + //SearchHelper(break down text fields, save to db) + //TagHelper(collection of tags??) + await ct.Widget.AddAsync(outObj); + return outObj; + } + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + /// GET + + //Get one + internal async Task GetAsync(long fetchId) + { + //This is simple so nothing more here, but often will be copying to a different output object or some other ops + return await ct.Widget.SingleOrDefaultAsync(m => m.Id == fetchId); + } + + //get many (paged) + internal async Task> GetManyAsync(IUrlHelper Url, string routeName, PagingOptions pagingOptions) + { + + pagingOptions.Offset = pagingOptions.Offset ?? PagingOptions.DefaultOffset; + pagingOptions.Limit = pagingOptions.Limit ?? PagingOptions.DefaultLimit; + + var items = await ct.Widget + .OrderBy(m => m.Id) + .Skip(pagingOptions.Offset.Value) + .Take(pagingOptions.Limit.Value) + .ToArrayAsync(); + + var totalRecordCount = await ct.Widget.CountAsync(); + var pageLinks = new PaginationLinkBuilder(Url, routeName, null, pagingOptions, totalRecordCount).PagingLinksObject(); + + ApiPagedResponse pr = new ApiPagedResponse(items, pageLinks); + return pr; + } + + + //get picklist (paged) + internal async Task> GetPickListAsync(IUrlHelper Url, string routeName, PagingOptions pagingOptions, string q) + { + pagingOptions.Offset = pagingOptions.Offset ?? PagingOptions.DefaultOffset; + pagingOptions.Limit = pagingOptions.Limit ?? PagingOptions.DefaultLimit; + + NameIdItem[] items; + int totalRecordCount = 0; + + if (!string.IsNullOrWhiteSpace(q)) + { + items = await ct.Widget + .Where(m => EF.Functions.ILike(m.Name, q)) + .OrderBy(m => m.Name) + .Skip(pagingOptions.Offset.Value) + .Take(pagingOptions.Limit.Value) + .Select(m => new NameIdItem() + { + Id = m.Id, + Name = m.Name + }).ToArrayAsync(); + + totalRecordCount = await ct.Widget.Where(m => EF.Functions.ILike(m.Name, q)).CountAsync(); + } + else + { + items = await ct.Widget + .OrderBy(m => m.Name) + .Skip(pagingOptions.Offset.Value) + .Take(pagingOptions.Limit.Value) + .Select(m => new NameIdItem() + { + Id = m.Id, + Name = m.Name + }).ToArrayAsync(); + + totalRecordCount = await ct.Widget.CountAsync(); + } + + + + var pageLinks = new PaginationLinkBuilder(Url, routeName, null, pagingOptions, totalRecordCount).PagingLinksObject(); + + ApiPagedResponse pr = new ApiPagedResponse(items, pageLinks); + return pr; + } + + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //UPDATE + // + + //put + internal bool Put(Widget dbObj, Widget inObj) + { + + //Replace the db object with the PUT object + CopyObject.Copy(inObj, dbObj, "Id"); + //Set "original" value of concurrency token to input token + //this will allow EF to check it out + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = inObj.ConcurrencyToken; + + Validate(dbObj, false); + if (HasErrors) + return false; + + return true; + } + + //patch + internal bool Patch(Widget dbObj, JsonPatchDocument objectPatch, uint concurrencyToken) + { + //Do the patching + objectPatch.ApplyTo(dbObj); + ct.Entry(dbObj).OriginalValues["ConcurrencyToken"] = concurrencyToken; + Validate(dbObj, false); + if (HasErrors) + return false; + + return true; + } + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //DELETE + // + + internal bool Delete(Widget dbObj) + { + //Determine if the object can be deleted, do the deletion tentatively + //Probably also in here deal with tags and associated search text etc + + ValidateCanDelete(dbObj); + if (HasErrors) + return false; + ct.Widget.Remove(dbObj); + return true; + } + + /// + /// Delete child objects like tags and attachments and etc + /// + /// + internal void DeleteChildren(Widget dbObj) + { + //TAGS + TagMapBiz.DeleteAllForObject(new AyaTypeId(AyaType.Widget, dbObj.Id), ct); + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + //VALIDATION + // + + //Can save or update? + private void Validate(Widget inObj, bool isNew) + { + //run validation and biz rules + if (isNew) + { + //NEW widgets must be active + if (inObj.Active == null || ((bool)inObj.Active) == false) + { + AddError(ValidationErrorType.InvalidValue, "Active", "New widget must be active"); + } + } + + //OwnerId required + if (!isNew) + { + if (inObj.OwnerId == 0) + AddError(ValidationErrorType.RequiredPropertyEmpty, "OwnerId"); + } + + //Name required + if (string.IsNullOrWhiteSpace(inObj.Name)) + AddError(ValidationErrorType.RequiredPropertyEmpty, "Name"); + + //Name must be less than 255 characters + if (inObj.Name.Length > 255) + AddError(ValidationErrorType.LengthExceeded, "Name", "255 max"); + + //If name is otherwise OK, check that name is unique + if (!PropertyHasErrors("Name")) + { + //Use Any command is efficient way to check existance, it doesn't return the record, just a true or false + if (ct.Widget.Any(m => m.Name == inObj.Name && m.Id != inObj.Id)) + { + AddError(ValidationErrorType.NotUnique, "Name"); + } + } + + //Start date AND end date must both be null or both contain values + if (inObj.StartDate == null && inObj.EndDate != null) + AddError(ValidationErrorType.RequiredPropertyEmpty, "StartDate"); + + if (inObj.StartDate != null && inObj.EndDate == null) + AddError(ValidationErrorType.RequiredPropertyEmpty, "EndDate"); + + //Start date before end date + if (inObj.StartDate != null && inObj.EndDate != null) + if (inObj.StartDate > inObj.EndDate) + AddError(ValidationErrorType.StartDateMustComeBeforeEndDate, "StartDate"); + + //Enum is valid value + + if (!inObj.Roles.IsValid()) + { + AddError(ValidationErrorType.InvalidValue, "Roles"); + } + + + return; + } + + + //Can delete? + private void ValidateCanDelete(Widget inObj) + { + //whatever needs to be check to delete this object + + } + + + + //////////////////////////////////////////////////////////////////////////////////////////////// + //JOB / OPERATIONS + // + public async Task HandleJobAsync(OpsJob job) + { + //Hand off the particular job to the corresponding processing code + //NOTE: If this code throws an exception the caller (JobsBiz::ProcessJobsAsync) will automatically set the job to failed and log the exeption so + //basically any error condition during job processing should throw up an exception if it can't be handled + switch (job.JobType) + { + case JobType.TestWidgetJob: + await ProcessTestJobAsync(job); + break; + default: + throw new System.ArgumentOutOfRangeException($"WidgetBiz.HandleJob-> Invalid job type{job.JobType.ToString()}"); + } + } + + + /// + /// /// Handle the test job + /// + /// + private async Task ProcessTestJobAsync(OpsJob job) + { + var sleepTime = 30 * 1000; + //Simulate a long running job here + JobsBiz.UpdateJobStatus(job.GId, JobStatus.Running, ct); + JobsBiz.LogJob(job.GId, $"WidgetBiz::ProcessTestJob started, sleeping for {sleepTime} seconds...", ct); + //Uncomment this to test if the job prevents other routes from running + //result is NO it doesn't prevent other requests, so we are a-ok for now + await Task.Delay(sleepTime); + JobsBiz.LogJob(job.GId, "WidgetBiz::ProcessTestJob done sleeping setting job to finished", ct); + JobsBiz.UpdateJobStatus(job.GId, JobStatus.Completed, ct); + + } + + //Other job handlers here... + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/generator/BackgroundService.cs b/server/AyaNova/generator/BackgroundService.cs new file mode 100644 index 00000000..8f27994d --- /dev/null +++ b/server/AyaNova/generator/BackgroundService.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + + + +namespace AyaNova.Generator +{ + + + //This is a temporary class until .net 2.1 is released + //https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/background-tasks-with-ihostedservice + + + // Copyright (c) .NET Foundation. Licensed under the Apache License, Version 2.0. + /// + /// Base class for implementing a long running . + /// + public abstract class BackgroundService : IHostedService, IDisposable + { + private Task _executingTask; + private readonly CancellationTokenSource _stoppingCts = + new CancellationTokenSource(); + + protected abstract Task ExecuteAsync(CancellationToken stoppingToken); + + public virtual Task StartAsync(CancellationToken cancellationToken) + { + // Store the task we're executing + _executingTask = ExecuteAsync(_stoppingCts.Token); + + // If the task is completed then return it, + // this will bubble cancellation and failure to the caller + if (_executingTask.IsCompleted) + { + return _executingTask; + } + + // Otherwise it's running + return Task.CompletedTask; + } + + public virtual async Task StopAsync(CancellationToken cancellationToken) + { + // Stop called without start + if (_executingTask == null) + { + return; + } + + try + { + // Signal cancellation to the executing method + _stoppingCts.Cancel(); + } + finally + { + // Wait until the task completes or the stop token triggers + await Task.WhenAny(_executingTask, Task.Delay(Timeout.Infinite, + cancellationToken)); + } + + } + + public virtual void Dispose() + { + _stoppingCts.Cancel(); + } + } + +} \ No newline at end of file diff --git a/server/AyaNova/generator/CoreJobMetricsReport.cs b/server/AyaNova/generator/CoreJobMetricsReport.cs new file mode 100644 index 00000000..0a093fec --- /dev/null +++ b/server/AyaNova/generator/CoreJobMetricsReport.cs @@ -0,0 +1,54 @@ +using System; +using System.Threading.Tasks; +using App.Metrics; +using AyaNova.Util; +using System.Linq; + + +namespace AyaNova.Biz +{ + + + /// + /// called by Generator to flush metrics to reporter + /// + /// + internal static class CoreJobMetricsReport + { + private static TimeSpan DO_EVERY_INTERVAL = new TimeSpan(0, 0, 20);//FLUSH EVERY 20 SECONDS + private static DateTime lastReportFlushDone = DateTime.MinValue; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // DoAsync + // + public static async Task DoJobAsync() + { + //https://www.app-metrics.io/ + IMetrics metrics = (IMetrics)ServiceProviderProvider.Provider.GetService(typeof(IMetrics)); + + //No more quickly than doeveryinterval + if (!DateUtil.IsAfterDuration(lastReportFlushDone, DO_EVERY_INTERVAL)) + return; + + //RUN ALL REPORTS - FLUSH STATS + var mr = (IMetricsRoot)metrics; + Task.WaitAll(mr.ReportRunner.RunAllAsync().ToArray()); + + lastReportFlushDone = DateTime.UtcNow; + + //just to hide compiler warning for now + await Task.CompletedTask; + + } + + + + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/generator/CoreJobMetricsSnapshot.cs b/server/AyaNova/generator/CoreJobMetricsSnapshot.cs new file mode 100644 index 00000000..322a4149 --- /dev/null +++ b/server/AyaNova/generator/CoreJobMetricsSnapshot.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Diagnostics; +using Microsoft.Extensions.Logging; +using App.Metrics; +using AyaNova.Util; +using AyaNova.Models; + + + +namespace AyaNova.Biz +{ + + + /// + /// called by Generator to gather server metrics and check on things + /// See MetricsRegistry for defined metrics + /// + /// + internal static class CoreJobMetricsSnapshot + { + private static ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger("CoreJobMetricsSnapshot"); + +#if (DEBUG) + private static TimeSpan DO_EVERY_INTERVAL = new TimeSpan(0, 1, 0);//DEBUG do a check every 60 seconds +#else + private static TimeSpan DO_EVERY_INTERVAL = new TimeSpan(0, 15, 0);//RELEASE do a check every 15 minutes +#endif + + private static DateTime lastServerCheckDone = DateTime.MinValue; + private static DateTime lastRecordCountCheck = DateTime.MinValue; + private static DateTime lastFileCountCheck = DateTime.MinValue; + + //////////////////////////////////////////////////////////////////////////////////////////////// + // DoAsync + // + public static async Task DoJobAsync(AyContext ct) + { + //https://www.app-metrics.io/ + IMetrics metrics = (IMetrics)ServiceProviderProvider.Provider.GetService(typeof(IMetrics)); + + //This will get triggered roughly every minute (10 seconds in debug), but we don't want to healthcheck that frequently + if (!DateUtil.IsAfterDuration(lastServerCheckDone, DO_EVERY_INTERVAL)) + return; + + log.LogTrace("Starting metrics snapshot"); + + //Gather core metrics here + var process = Process.GetCurrentProcess(); + + //PHYSICAL MEMORY + metrics.Measure.Gauge.SetValue(MetricsRegistry.PhysicalMemoryGauge, process.WorkingSet64); + + //PRIVATE BYTES + metrics.Measure.Gauge.SetValue(MetricsRegistry.PrivateBytesGauge, process.PrivateMemorySize64); + + //RECORDS IN TABLE + //Only do this once per hour + if (DateUtil.IsAfterDuration(lastRecordCountCheck, 1)) + { + lastRecordCountCheck = DateTime.UtcNow; + log.LogTrace("Counting table records"); + + + //Get a count of important tables in db + List allTableNames = DbUtil.GetAllTablenames(); + + //Skip some tables as they are internal and / or only ever have one record + List skipTableNames = new List(); + skipTableNames.Add("alicense"); + skipTableNames.Add("aschemaversion"); + + foreach (string table in allTableNames) + { + if (!skipTableNames.Contains(table)) + { + var tags = new MetricTags("TableTagKey", table); + metrics.Measure.Gauge.SetValue(MetricsRegistry.DBRecordsGauge, tags, DbUtil.CountOfRecords(table)); + } + } + } + + //JOB COUNTS (DEAD, RUNNING, COMPLETED, SLEEPING) + + foreach (JobStatus stat in Enum.GetValues(typeof(JobStatus))) + { + var jobtag = new MetricTags("JobStatus", stat.ToString()); + metrics.Measure.Gauge.SetValue(MetricsRegistry.JobsGauge, jobtag, await JobsBiz.GetCountForJobStatusAsync(ct, stat)); + } + + + + //FILES ON DISK + //Only do this once per hour + if (DateUtil.IsAfterDuration(lastFileCountCheck, 1)) + { + lastFileCountCheck = DateTime.UtcNow; + log.LogTrace("Files on disk information"); + var UtilFilesInfo = FileUtil.GetUtilityFolderSizeInfo(); + var UserFilesInfo = FileUtil.GetAttachmentFolderSizeInfo(); + + var mtag = new MetricTags("File type", "Business object files"); + metrics.Measure.Gauge.SetValue(MetricsRegistry.FileCountGauge, mtag, UserFilesInfo.FileCountWithChildren); + metrics.Measure.Gauge.SetValue(MetricsRegistry.FileSizeGauge, mtag, UserFilesInfo.SizeWithChildren); + + mtag = new MetricTags("File type", "OPS files"); + metrics.Measure.Gauge.SetValue(MetricsRegistry.FileCountGauge, mtag, UtilFilesInfo.FileCountWithChildren); + metrics.Measure.Gauge.SetValue(MetricsRegistry.FileSizeGauge, mtag, UtilFilesInfo.SizeWithChildren); + + } + + + lastServerCheckDone = DateTime.UtcNow; + + + //just to hide compiler warning for now + await Task.CompletedTask; + + } + + + + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/generator/CoreJobSweeper.cs b/server/AyaNova/generator/CoreJobSweeper.cs new file mode 100644 index 00000000..81dbea75 --- /dev/null +++ b/server/AyaNova/generator/CoreJobSweeper.cs @@ -0,0 +1,127 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.JsonPatch; +using Microsoft.Extensions.Logging; +using EnumsNET; +using AyaNova.Util; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; +using AyaNova.Models; + + +namespace AyaNova.Biz +{ + + + /// + /// JobSweeper - called by Generator to clean out old jobs that are completed and their logs + /// + /// + internal static class CoreJobSweeper + { + private static ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger("CoreJobSweeper"); + private static DateTime lastSweep = DateTime.MinValue; + private static TimeSpan SWEEP_EVERY_INTERVAL = new TimeSpan(0, 30, 0); + private static TimeSpan SUCCEEDED_JOBS_DELETE_AFTER_THIS_TIMESPAN = new TimeSpan(24, 0, 0);//24 hours + private static TimeSpan FAILED_JOBS_DELETE_AFTER_THIS_TIMESPAN = new TimeSpan(14, 0, 0, 0);//14 days (gives people time to notice and look into it) + private static TimeSpan RUNNING_JOBS_BECOME_FAILED_AFTER_THIS_TIMESPAN = new TimeSpan(24, 0, 0);//24 hours (time running jobs are allowed to sit in "running" state before considered failed) + + //////////////////////////////////////////////////////////////////////////////////////////////// + // DoSweep + // + public static async Task DoSweepAsync(AyContext ct) + { + + + //This will get triggered roughly every minute, but we don't want to sweep that frequently + if (DateTime.UtcNow - lastSweep < SWEEP_EVERY_INTERVAL) + return; + + log.LogTrace("Sweep starting"); + + //SWEEP SUCCESSFUL JOBS + //calculate cutoff to delete + DateTime dtDeleteCutoff = DateTime.UtcNow - SUCCEEDED_JOBS_DELETE_AFTER_THIS_TIMESPAN; + await sweepAsync(ct, dtDeleteCutoff, JobStatus.Completed); + + //SWEEP FAILED JOBS + //calculate cutoff to delete + dtDeleteCutoff = DateTime.UtcNow - FAILED_JOBS_DELETE_AFTER_THIS_TIMESPAN; + await sweepAsync(ct, dtDeleteCutoff, JobStatus.Failed); + + + //KILL STUCK JOBS + //calculate cutoff to delete + DateTime dtRunningDeadline = DateTime.UtcNow - RUNNING_JOBS_BECOME_FAILED_AFTER_THIS_TIMESPAN; + await killStuckJobsAsync(ct, dtRunningDeadline); + + lastSweep = DateTime.UtcNow; + } + + + private static async Task sweepAsync(AyContext ct, DateTime dtDeleteCutoff, JobStatus jobStatus) + { + + //Get the deleteable succeeded jobs list + var jobs = await ct.OpsJob + .AsNoTracking() + .Where(c => c.Created < dtDeleteCutoff && c.JobStatus == jobStatus) + .OrderBy(m => m.Created) + .ToListAsync(); + + log.LogTrace($"SweepAsync processing: cutoff={dtDeleteCutoff.ToString()}, for {jobs.Count.ToString()} jobs of status {jobStatus.ToString()}"); + + foreach (OpsJob j in jobs) + { + try + { + await JobsBiz.DeleteJobAndLogAsync(j.GId, ct); + } + catch (Exception ex) + { + log.LogError(ex, "sweepAsync exception calling JobsBiz.DeleteJobAndLogAsync"); + //for now just throw it but this needs to be removed when logging added and better handling + throw (ex); + } + } + } + + + /// + /// Kill jobs that have been stuck in "running" state for too long + /// + /// + /// + /// + private static async Task killStuckJobsAsync(AyContext ct, DateTime dtRunningDeadline) + { + //Get the deleteable succeeded jobs list + var jobs = await ct.OpsJob + .AsNoTracking() + .Where(c => c.Created < dtRunningDeadline && c.JobStatus == JobStatus.Running) + .OrderBy(m => m.Created) + .ToListAsync(); + + log.LogTrace($"killStuckJobsAsync processing: cutoff={dtRunningDeadline.ToString()}, for {jobs.Count.ToString()} jobs of status {JobStatus.Running.ToString()}"); + + foreach (OpsJob j in jobs) + { + //OPSMETRIC + JobsBiz.LogJob(j.GId, "Job took too long to run - setting to failed", ct); + log.LogError($"Job found job stuck in running status and set to failed: deadline={dtRunningDeadline.ToString()}, jobId={j.GId.ToString()}, jobname={j.Name}, jobtype={j.JobType.ToString()}, jobObjectType={j.ObjectType.ToString()}, jobObjectId={j.ObjectId.ToString()}"); + JobsBiz.UpdateJobStatus(j.GId, JobStatus.Failed, ct); + } + } + + + ///////////////////////////////////////////////////////////////////// + + }//eoc + + +}//eons + diff --git a/server/AyaNova/generator/Generate.cs b/server/AyaNova/generator/Generate.cs new file mode 100644 index 00000000..e627622c --- /dev/null +++ b/server/AyaNova/generator/Generate.cs @@ -0,0 +1,124 @@ +using System.Threading; +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using AyaNova.Models; +using AyaNova.Api.ControllerHelpers; +using AyaNova.Biz; + +namespace AyaNova.Generator +{ + + //Implemented from a example here + //https://docs.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/background-tasks-with-ihostedservice + + + /* + TODO: Generator tasks that should happen: + - Periodically erase any temp files written to userfiles root (attachments temp files) that are older than a day + - These files should be normally erased within seconds after uploading and processing into their permanent folder but shit will go wrong + */ + + public class GeneratorService : BackgroundService + { + private readonly ILogger log; + // private readonly AyContext ct; + // private readonly ApiServerState serverState; + + private readonly IServiceProvider provider; + +#if(DEBUG) + private const int GENERATE_SECONDS = 10; +#else + private const int GENERATE_SECONDS = 60; +#endif + + // public GeneratorService(ILogger logger, AyContext dbcontext, ApiServerState apiServerState) + // { + // ct = dbcontext; + // log = logger; + // serverState = apiServerState; + // } + + + public GeneratorService(ILogger logger, IServiceProvider serviceProvider) + { + // ct = dbcontext; + provider = serviceProvider; + + log = logger; + // serverState = apiServerState; + } + + + + + + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + //don't immediately run the generator stuff on boot + bool justStarted = false; + + log.LogDebug($"GeneratorService is starting."); + + stoppingToken.Register(() => + log.LogDebug($" GeneratorService background task is stopping.")); + + while (!stoppingToken.IsCancellationRequested) + { + + if (!justStarted) + { + log.LogDebug($"GeneratorService task doing background work."); + + using (IServiceScope scope = provider.CreateScope()) + { + AyContext ct = scope.ServiceProvider.GetRequiredService(); + ApiServerState serverState = scope.ServiceProvider.GetRequiredService(); + + if (!serverState.IsOpen) + { + log.LogDebug($"GeneratorService: ServerState is closed returning without processing jobs, will try again next iteration"); + } + + //================================================================= + try + { + await JobsBiz.ProcessJobsAsync(ct); + } + catch (Exception ex) + { + log.LogError("Generate::ProcessJobs resulted in exception error ", ex); + + } + //================================================================= + } + } + await Task.Delay((GENERATE_SECONDS * 1000), stoppingToken); + justStarted = false; + } + + log.LogDebug($"GeneratorService background task is stopping."); + + } + + //originally but kept getting compiler error + // public override async Task StopAsync(CancellationToken stoppingToken) + // { + // log.LogDebug($"GeneratorService StopAsync triggered."); + + // // Run your graceful clean-up actions + // } + + + public override Task StopAsync(CancellationToken stoppingToken) + { + log.LogDebug($"GeneratorService StopAsync triggered."); + return Task.FromResult(0); + // Run your graceful clean-up actions + } + } + +} \ No newline at end of file diff --git a/server/AyaNova/models/AyContext.cs b/server/AyaNova/models/AyContext.cs new file mode 100644 index 00000000..a7a888c2 --- /dev/null +++ b/server/AyaNova/models/AyContext.cs @@ -0,0 +1,91 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using AyaNova.Util; +using Microsoft.Extensions.Logging; + +namespace AyaNova.Models +{ + public partial class AyContext : DbContext + { + public virtual DbSet User { get; set; } + public virtual DbSet License { get; set; } + public virtual DbSet Widget { get; set; } + public virtual DbSet FileAttachment { get; set; } + public virtual DbSet Tag { get; set; } + public virtual DbSet TagMap { get; set; } + public virtual DbSet OpsJob { get; set; } + public virtual DbSet OpsJobLog { get; set; } + public virtual DbSet Locale { get; set; } + public virtual DbSet LocaleItem { get; set; } + + //Note: had to add this constructor to work with the code in startup.cs that gets the connection string from the appsettings.json file + //and commented out the above on configuring + public AyContext(DbContextOptions options) : base(options) + { } + + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + + //https://andrewlock.net/customising-asp-net-core-identity-ef-core-naming-conventions-for-postgresql/ + foreach (var entity in modelBuilder.Model.GetEntityTypes()) + { + // Replace table names + entity.Relational().TableName = "a" + entity.Relational().TableName.ToLowerInvariant(); + + // Replace column names + foreach (var property in entity.GetProperties()) + { + //Any object that has a concurrencytoken field + //set it up to work properly with PostgreSQL + if (property.Name == "ConcurrencyToken") + { + property.Relational().ColumnName = "xmin"; + property.Relational().ColumnType = "xid"; + property.ValueGenerated = ValueGenerated.OnAddOrUpdate; + property.IsConcurrencyToken = true; + } + else + property.Relational().ColumnName = property.Name.ToLowerInvariant(); + + } + + foreach (var key in entity.GetKeys()) + { + key.Relational().Name = key.Relational().Name.ToLowerInvariant(); + } + + foreach (var key in entity.GetForeignKeys()) + { + key.Relational().Name = key.Relational().Name.ToLowerInvariant(); + } + + foreach (var index in entity.GetIndexes()) + { + index.Relational().Name = index.Relational().Name.ToLowerInvariant(); + } + + + } + + //Indexes must be specified through fluent api unfortunately + modelBuilder.Entity().HasIndex(p => p.StoredFileName); + + + //Relationships + modelBuilder.Entity() + .HasMany(c => c.LocaleItems) + .WithOne(e => e.Locale) + .IsRequired();//default delete behaviour is cascade when set to isrequired + + + + + //----------- + } + + } + +} \ No newline at end of file diff --git a/server/AyaNova/models/FileAttachment.cs b/server/AyaNova/models/FileAttachment.cs new file mode 100644 index 00000000..57425c6f --- /dev/null +++ b/server/AyaNova/models/FileAttachment.cs @@ -0,0 +1,37 @@ +using System; +using AyaNova.Biz; +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + public partial class FileAttachment + { + public long Id { get; set; } + public uint ConcurrencyToken { get; set; } + public DateTime Created { get; set; }//time it was uploaded not original file creation time, we don't have that + [Required] + public long OwnerId { get; set; } + //----------------------------------------- + [Required] + public long AttachToObjectId { get; set; } + [Required] + public AyaType AttachToObjectType { get; set; }//int + [Required] + public string StoredFileName { get; set; } + [Required] + public string DisplayFileName { get; set; } + [Required] + public string ContentType { get; set; }//mime type + public string Notes { get; set; } + + + public FileAttachment() + { + Created = System.DateTime.UtcNow; + } + + } + +} +//"AttachToObjectType and / or AttachToObjectId public AuthorizationRoles Roles { get; set; } \ No newline at end of file diff --git a/server/AyaNova/models/License.cs b/server/AyaNova/models/License.cs new file mode 100644 index 00000000..88ed617c --- /dev/null +++ b/server/AyaNova/models/License.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +namespace AyaNova.Models +{ + + public partial class License + { + public long Id { get; set; } + public string Key { get; set; } + public Guid DbId { get; set; } + public int LastFetchStatus { get; set; } + public string LastFetchMessage { get; set; } + + + + } + +} diff --git a/server/AyaNova/models/Locale.cs b/server/AyaNova/models/Locale.cs new file mode 100644 index 00000000..ffd2863e --- /dev/null +++ b/server/AyaNova/models/Locale.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +using Newtonsoft.Json; + +namespace AyaNova.Models +{ +// [JsonObject(IsReference = true)] + public partial class Locale + { + public long Id { get; set; } + public uint ConcurrencyToken { get; set; } + [Required] + public long OwnerId { get; set; } + + [Required] + public string Name { get; set; } + public bool? Stock { get; set; } + public DateTime Created { get; set; } + + + public Locale() + { + Created = System.DateTime.UtcNow; + } + + + + //Relationship + //was this but.. + // public ICollection LocaleItems { get; set; } + + //Not perhaps so useful here but this is a good way to lazy initialize collections which + //is more efficient when there are many child collections (workorder) and means no need to null check the collection + //https://stackoverflow.com/a/20773057/8939 + + private ICollection _localeItem; + public virtual ICollection LocaleItems + { + get + { + return this._localeItem ?? (this._localeItem = new HashSet()); + } + } + + + }//eoc + +}//eons diff --git a/server/AyaNova/models/LocaleItem.cs b/server/AyaNova/models/LocaleItem.cs new file mode 100644 index 00000000..c3ea9a15 --- /dev/null +++ b/server/AyaNova/models/LocaleItem.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +using Newtonsoft.Json; + +namespace AyaNova.Models +{ + + public partial class LocaleItem + { + public long Id { get; set; } + public uint ConcurrencyToken { get; set; } + + [Required] + public string Key { get; set; } + [Required] + public string Display { get; set; } + + public long LocaleId { get; set; } + + //Relation + [JsonIgnore] + public Locale Locale { get; set; } + } +} diff --git a/server/AyaNova/models/OpsJob.cs b/server/AyaNova/models/OpsJob.cs new file mode 100644 index 00000000..537b4dd0 --- /dev/null +++ b/server/AyaNova/models/OpsJob.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + /// + /// Operations job + /// + public partial class OpsJob + { + [Key] + public Guid GId { get; set; } + + [Required] + public DateTime Created { get; set; } + public uint ConcurrencyToken { get; set; } + + [Required] + public long OwnerId { get; set; } + [Required] + public string Name { get; set; } + + //------------------------ + [Required] + public bool Exclusive { get; set; }//true lock api and don't run other jobs until completed / false=run any time with other jobs async + public DateTime StartAfter { get; set; } + [Required] + public JobType JobType { get; set; } + public long ObjectId { get; set; } + public AyaType ObjectType { get; set; } + [Required] + public JobStatus JobStatus { get; set; } + /// + /// Json string of any required extra info for job + /// + public string JobInfo { get; set; }//json as string of any required extra info for job + + + public OpsJob(){ + GId=new Guid(); + Created=DateTime.UtcNow; + OwnerId=1; + Name="new job"; + Exclusive=false; + StartAfter=Created; + JobType=JobType.NotSet; + ObjectId=0; + ObjectType=AyaType.NotValid; + JobStatus=JobStatus.Sleeping; + JobInfo=null; + + } + + } + +} diff --git a/server/AyaNova/models/OpsJobLog.cs b/server/AyaNova/models/OpsJobLog.cs new file mode 100644 index 00000000..5da1cd57 --- /dev/null +++ b/server/AyaNova/models/OpsJobLog.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + /// + /// Operations job log + /// + public partial class OpsJobLog + { + [Key] + public Guid GId { get; set; } + + [Required] + public Guid JobId { get; set; } + [Required] + public DateTime Created { get; set; } + [Required] + public string StatusText { get; set; } + + + + public OpsJobLog(){ + GId=new Guid(); + Created=DateTime.UtcNow; + StatusText="default / not set"; + } + + } + +} diff --git a/server/AyaNova/models/Tag.cs b/server/AyaNova/models/Tag.cs new file mode 100644 index 00000000..d4da1057 --- /dev/null +++ b/server/AyaNova/models/Tag.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + public partial class Tag + { + public long Id { get; set; } + public DateTime Created { get; set; } + public uint ConcurrencyToken { get; set; } + + [Required] + public long OwnerId { get; set; } + [Required] + public string Name { get; set; }//max 35 characters ascii set + + + public Tag() + { + Created = System.DateTime.UtcNow; + } + + + } + +} diff --git a/server/AyaNova/models/TagMap.cs b/server/AyaNova/models/TagMap.cs new file mode 100644 index 00000000..156fe545 --- /dev/null +++ b/server/AyaNova/models/TagMap.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + public partial class TagMap + { + public long Id { get; set; } + public uint ConcurrencyToken { get; set; } + [Required] + public long OwnerId { get; set; } + public DateTime Created { get; set; } + + [Required] + public long TagId { get; set; } + [Required] + public long TagToObjectId { get; set; } + [Required] + public AyaType TagToObjectType { get; set; } + + + public TagMap() + { + Created = System.DateTime.UtcNow; + } + + } + +} diff --git a/server/AyaNova/models/User.cs b/server/AyaNova/models/User.cs new file mode 100644 index 00000000..5c78c1f8 --- /dev/null +++ b/server/AyaNova/models/User.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; +using System.ComponentModel.DataAnnotations; +namespace AyaNova.Models +{ + + public partial class User + { + public long Id { get; set; } + public DateTime Created { get; set; } + public uint ConcurrencyToken { get; set; } + [Required] + public long OwnerId { get; set; } + public string Name { get; set; } + public string Login { get; set; } + public string Password { get; set; } + public string Salt { get; set; } + public AuthorizationRoles Roles { get; set; } + public string DlKey { get; set; } + public DateTime? DlKeyExpire { get; set; } + public long LocaleId { get; set; } + + + public User() + { + Created = System.DateTime.UtcNow; + } + + } + +} diff --git a/server/AyaNova/models/Widget.cs b/server/AyaNova/models/Widget.cs new file mode 100644 index 00000000..109fb913 --- /dev/null +++ b/server/AyaNova/models/Widget.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using AyaNova.Biz; + +using System.ComponentModel.DataAnnotations; + +namespace AyaNova.Models +{ + + //Test class for development + public partial class Widget + { + public long Id { get; set; } + public uint ConcurrencyToken { get; set; } + + [Required] + public long OwnerId { get; set; } + public string Name { get; set; } + public DateTime Created { get; set; } + public decimal? DollarAmount { get; set; } + public bool? Active { get; set; } + public AuthorizationRoles Roles { get; set; } + public DateTime? StartDate { get; set; } + public DateTime? EndDate { get; set; } + + + public Widget() + { + Created = System.DateTime.UtcNow; + } + + + } + +} diff --git a/server/AyaNova/models/dto/ImportV7MapItem.cs b/server/AyaNova/models/dto/ImportV7MapItem.cs new file mode 100644 index 00000000..5c7106fa --- /dev/null +++ b/server/AyaNova/models/dto/ImportV7MapItem.cs @@ -0,0 +1,20 @@ +using System; +using AyaNova.Biz; +namespace AyaNova.Models +{ + + /// + /// Provides mapping during import + /// + public partial class ImportAyaNova7MapItem + { + public AyaTypeId NewObject { get; set; } + public Guid V7ObjectId { get; set; } + + public ImportAyaNova7MapItem(Guid v7Guid, AyaType ayaType, long newId){ + NewObject=new AyaTypeId(ayaType,newId); + V7ObjectId=v7Guid; + } + } + +} diff --git a/server/AyaNova/models/dto/JobOperationsFetchInfo.cs b/server/AyaNova/models/dto/JobOperationsFetchInfo.cs new file mode 100644 index 00000000..2b728487 --- /dev/null +++ b/server/AyaNova/models/dto/JobOperationsFetchInfo.cs @@ -0,0 +1,40 @@ +using AyaNova.Biz; +using System; + +namespace AyaNova.Models +{ + /// + /// Job info fetch data + /// + public class JobOperationsFetchInfo + { + + /// + /// Identity of the job + /// + /// id value of job, can be used to fetch logs + public Guid GId { get; set; } + /// + /// Date job was submitted + /// + /// UTC date/time + public DateTime Created { get; set; } + /// + /// Date of the most recent operation for this job + /// + /// UTC date/time + public DateTime LastAction { get; set; } + /// + /// Descriptive name of the job + /// + /// string + public string Name { get; set; } + /// + /// Status of the job + /// + /// Job status as string + public string JobStatus { get; set; } + + } + +} diff --git a/server/AyaNova/models/dto/JobOperationsLogInfoItem.cs b/server/AyaNova/models/dto/JobOperationsLogInfoItem.cs new file mode 100644 index 00000000..8bf3dee5 --- /dev/null +++ b/server/AyaNova/models/dto/JobOperationsLogInfoItem.cs @@ -0,0 +1,26 @@ +using AyaNova.Biz; +using System; + +namespace AyaNova.Models +{ + /// + /// Job log item + /// + public class JobOperationsLogInfoItem + { + + /// + /// Date of log entry + /// + /// UTC date/time + public DateTime Created { get; set; } + + /// + /// Log text + /// + /// string + public string StatusText { get; set; } + + } + +} diff --git a/server/AyaNova/models/dto/NameIdActiveItem.cs b/server/AyaNova/models/dto/NameIdActiveItem.cs new file mode 100644 index 00000000..b132e2e9 --- /dev/null +++ b/server/AyaNova/models/dto/NameIdActiveItem.cs @@ -0,0 +1,12 @@ +namespace AyaNova.Models +{ + + public partial class NameIdActiveItem + { + public long Id { get; set; } + public string Name { get; set; } + public bool? Active { get; set; } + + } + +} diff --git a/server/AyaNova/models/dto/NameIdItem.cs b/server/AyaNova/models/dto/NameIdItem.cs new file mode 100644 index 00000000..6a98cba3 --- /dev/null +++ b/server/AyaNova/models/dto/NameIdItem.cs @@ -0,0 +1,10 @@ +namespace AyaNova.Models +{ + + public partial class NameIdItem + { + public long Id { get; set; } + public string Name { get; set; } + } + +} diff --git a/server/AyaNova/models/dto/NameItem.cs b/server/AyaNova/models/dto/NameItem.cs new file mode 100644 index 00000000..77b16a12 --- /dev/null +++ b/server/AyaNova/models/dto/NameItem.cs @@ -0,0 +1,12 @@ +namespace AyaNova.Models +{ + + /// + /// Dto object for name only parameters in routes + /// + public partial class NameItem + { + public string Name { get; set; } + } + +} diff --git a/server/AyaNova/models/dto/NewTextIdConcurrencyTokenItem.cs b/server/AyaNova/models/dto/NewTextIdConcurrencyTokenItem.cs new file mode 100644 index 00000000..1f684ca3 --- /dev/null +++ b/server/AyaNova/models/dto/NewTextIdConcurrencyTokenItem.cs @@ -0,0 +1,11 @@ +namespace AyaNova.Models +{ + + public partial class NewTextIdConcurrencyTokenItem + { + public long Id { get; set; } + public string NewText { get; set; } + public uint ConcurrencyToken { get; set; } + } + +} diff --git a/server/AyaNova/models/dto/TagMapInfo.cs b/server/AyaNova/models/dto/TagMapInfo.cs new file mode 100644 index 00000000..4c3eba24 --- /dev/null +++ b/server/AyaNova/models/dto/TagMapInfo.cs @@ -0,0 +1,14 @@ +using AyaNova.Biz; + +namespace AyaNova.Models +{ + + public class TagMapInfo + { + public long TagId { get; set; } + public long TagToObjectId { get; set; } + public AyaType TagToObjectType { get; set; } + } + + +} diff --git a/server/AyaNova/models/dto/TypeAndIdInfo.cs b/server/AyaNova/models/dto/TypeAndIdInfo.cs new file mode 100644 index 00000000..ec00e096 --- /dev/null +++ b/server/AyaNova/models/dto/TypeAndIdInfo.cs @@ -0,0 +1,13 @@ +using AyaNova.Biz; + +namespace AyaNova.Models +{ + + public class TypeAndIdInfo + { + public long ObjectId { get; set; } + public AyaType ObjectType { get; set; } + } + + +} diff --git a/server/AyaNova/models/dto/UploadedFileInfo.cs b/server/AyaNova/models/dto/UploadedFileInfo.cs new file mode 100644 index 00000000..bc96ba6e --- /dev/null +++ b/server/AyaNova/models/dto/UploadedFileInfo.cs @@ -0,0 +1,14 @@ +namespace AyaNova.Models +{ + + /// + /// Uploaded file info in it's temporary state + /// + public class UploadedFileInfo + { + public string InitialUploadedPathName { get; set; } + public string OriginalFileName { get; set; } + public string MimeType { get; set; } + } + +} diff --git a/server/AyaNova/resource/de.json b/server/AyaNova/resource/de.json new file mode 100644 index 00000000..e9f67a20 --- /dev/null +++ b/server/AyaNova/resource/de.json @@ -0,0 +1,1415 @@ +{ + "AddressType": "Adresstyp", + "AddressTypePhysical": "Physikalische Adresse", + "AddressTypePhysicalDescription": "Dies ist die Adresse, an der das Gebäude vorhanden ist, an das die Artikel geliefert werden", + "AddressTypePostal": "Postanschrift", + "AddressTypePostalDescription": "Dies ist die Adresse, an die Brief-/Paketpost gesendet wird", + "AddressCity": "Stadt", + "AddressCopyToPhysical": "Copy to physical address", + "AddressCopyToPostal": "Copy to postal address", + "AddressCountry": "Land", + "AddressCountryCode": "Ländercode", + "AddressDeliveryAddress": "Straße", + "AddressFullAddress": "Vollständige Adresse", + "AddressLatitude": "Breite", + "AddressLongitude": "Länge", + "AddressMapQuestURL": "MapQuest-Karte - Weblink", + "AddressPostal": "Postleitzahl", + "AddressPostalCity": "Stadt (Post)", + "AddressPostalCountry": "Land (Post)", + "AddressPostalDeliveryAddress": "Adresse (Post)", + "AddressPostalPostal": "Postleitzahl (Post)", + "AddressPostalStateProv": "Bundesland (Post)", + "AddressStateProv": "Bundesland", + "AdminEraseDatabase": "Gesamte AyaNova-Datenbank löschen", + "AdminEraseDatabaseLastWarning": "Warnung: Dies ist Ihre letzte Chance, das dauerhafte Löschen aller Daten zu vermeiden. Möchten Sie wirklich alle Daten löschen?", + "AdminEraseDatabaseWarning": "Warnung: Sie möchten alle Daten in AyaNova dauerhaft löschen. Möchten Sie wirklich fortfahren?", + "AdminPasteLicense": "Lizenzschlüssel einfügen", + "AssignedDocDescription": "Beschreibung", + "AssignedDocList": "Dokumente", + "AssignedDocURL": "Dokumentlink", + "AyaFileFileTooLarge": "File size exceeds limit of {0}", + "AyaFileFileSize": "Size", + "AyaFileFileSizeStored": "Size stored", + "AyaFileFileType": "Type", + "AyaFileList": "Files in database", + "AyaFileSource": "Source", + "ClientAccountNumber": "Kontonummer", + "ClientBillHeadOffice": "Hauptsitz belasten", + "ClientContact": "Contact", + "ClientContactNotes": "Other contacts", + "ClientCustom0": "Angepasstes Feld 0", + "ClientCustom1": "Angepasstes Feld 1", + "ClientCustom2": "Angepasstes Feld 2", + "ClientCustom3": "Angepasstes Feld 3", + "ClientCustom4": "Angepasstes Feld 4", + "ClientCustom5": "Angepasstes Feld 5", + "ClientCustom6": "Angepasstes Feld 6", + "ClientCustom7": "Angepasstes Feld 7", + "ClientCustom8": "Angepasstes Feld 8", + "ClientCustom9": "Angepasstes Feld 9", + "ClientEmail": "Email", + "ClientEventContractExpire": "Kunde - Vertrag läuft aus", + "ClientList": "Kunden", + "ClientName": "Kundenname", + "ClientNotes": "Allgemeine Anmerkungen", + "ClientNotification": "Send client notifications", + "ClientPhone1": "Business", + "ClientPhone2": "Fax", + "ClientPhone3": "Home", + "ClientPhone4": "Mobile", + "ClientPhone5": "Pager", + "ClientPopUpNotes": "Popup-Anmerkungen", + "ClientTechNotes": "Planbare Benutzer - Anmerkungen", + "ClientGroupDescription": "Beschreibung", + "ClientGroupList": "Kundengruppen", + "ClientGroupName": "Kundengruppe - Name", + "ClientNoteClientNoteTypeID": "Kundenanmerkungstyp", + "ClientNoteList": "Kundenanmerkungen", + "ClientNoteNoteDate": "Datum der Anmerkung", + "ClientNoteNotes": "Anmerkungen", + "ClientNoteTypeList": "Kundenanmerkungstypen", + "ClientNoteTypeName": "Kundenanmerkungstyp - Name", + "ClientRequestPartClientServiceRequestItemID": "Kundenserviceanforderung - Posten", + "ClientRequestPartPrice": "Preis", + "ClientRequestPartQuantity": "Menge", + "ClientRequestTechClientServiceRequestItemID": "Kundenserviceanforderung - Posten", + "ClientRequestTechScheduledStartDate": "Angefordertes geplantes Startdatum", + "ClientRequestTechScheduledStopDate": "Angefordertes geplantes Enddatum", + "ClientRequestTechUserID": "Angeforderter planbarer Benutzer", + "ClientServiceRequestAcceptToExisting": "Accept to existing work order", + "ClientServiceRequestAcceptToNew": "Accept to new work order", + "ClientServiceRequestCustomContactName": "Ansprechpartner - Name", + "ClientServiceRequestCustomerReferenceNumber": "Referenznummer", + "ClientServiceRequestDetailedServiceToBePerformed": "Durchzuführender Service - Details", + "ClientServiceRequestDetails": "Details", + "ClientServiceRequestEventCreated": "Client service request - New", + "ClientServiceRequestEventCreatedUpdated": "Kundenserviceanforderung - neu/aktualisiert", + "ClientServiceRequestList": "Customer service requests", + "ClientServiceRequestOnsite": "Vor Ort", + "ClientServiceRequestParts": "Teile", + "ClientServiceRequestPreferredTechs": "Angeforderte planbare Benutzer", + "ClientServiceRequestPriority": "Priorität", + "ClientServiceRequestReject": "Reject service request", + "ClientServiceRequestRequestedBy": "Requested by", + "ClientServiceRequestStatus": "Status", + "ClientServiceRequestTitle": "Title", + "ClientServiceRequestWorkorderItems": "Angeforderte Serviceposten", + "ClientServiceRequestItemServiceToBePerformed": "Durchzuführender Service - Zusammenfassung", + "ClientServiceRequestItemUnitID": "Einheit", + "ClientServiceRequestPriorityASAP": "ASAP", + "ClientServiceRequestPriorityEmergency": "Emergency", + "ClientServiceRequestPriorityNotUrgent": "Not urgent", + "ClientServiceRequestStatusAccepted": "Accepted", + "ClientServiceRequestStatusClosed": "Closed", + "ClientServiceRequestStatusDeclined": "Declined", + "ClientServiceRequestStatusOpen": "Open", + "CommonActive": "Aktiv", + "CommonContractExpires": "Vertrag läuft ab", + "CommonCost": "Kosten", + "CommonCreated": "Datensatz erstellt", + "CommonCreator": "Datensatz erstellt von", + "CommonDefaultLanguage": "Standardsprache", + "CommonDescription": "Beschreibung", + "CommonID": "Eindeutige Kennzeichnungsnummer", + "CommonModified": "Datensatz zuletzt geändert am", + "CommonModifier": "Datensatz zuletzt geändert von", + "CommonMore": "More...", + "CommonName": "Name", + "CommonRootObject": "Stammobjekt", + "CommonRootObjectType": "Stammobjekttyp", + "CommonSerialNumber": "Seriennummer", + "CommonUsesBanking": "Service gutschreiben", + "CommonWebAddress": "Webadresse", + "ContactContactTitleID": "Titel", + "ContactDescription": "Beschreibung", + "ContactEmailAddress": "E-Mail-Adresse", + "ContactFirstName": "Vorname", + "ContactFullContact": "Ansprechpartner - vollständig", + "ContactJobTitle": "Stellenbezeichnung", + "ContactLastName": "Nachname", + "ContactPhones": "Telefonnummern", + "ContactPrimaryContact": "Hauptansprechpartner", + "ContactRootObjectID": "Stammobjekt-ID", + "ContactRootObjectType": "Stammobjekttyp", + "ContactPhoneContactID": "Ansprechpartner", + "ContactPhoneType": "Ansprechpartner - Telefontyp", + "ContactPhoneTypeBusiness": "Geschäft", + "ContactPhoneTypeFax": "Fax", + "ContactPhoneTypeHome": "Privat", + "ContactPhoneTypeMobile": "Handy", + "ContactPhoneTypePager": "Pager", + "ContactPhoneFullPhoneRecord": "Vollständige Telefonnummer", + "ContactPhoneAreaCode": "Ortsvorwahl", + "ContactPhoneCountryCode": "Ländercode", + "ContactPhoneDefault": "Standardtelefon", + "ContactPhoneExtension": "Nebenstelle", + "ContactPhoneNumber": "Telefonnummer", + "ContactPhoneTypeName": "Telefontyp - Name", + "ContactPhoneTypeObjectName": "Typ", + "ContactTitleList": "Anreden für Ansprechpartner", + "ContactTitleName": "Anrede", + "ContractContractRatesOnly": "Nur auf Vertragssätze beschränken", + "ContractCustom0": "Angepasstes Feld 0", + "ContractCustom1": "Angepasstes Feld 1", + "ContractCustom2": "Angepasstes Feld 2", + "ContractCustom3": "Angepasstes Feld 3", + "ContractCustom4": "Angepasstes Feld 4", + "ContractCustom5": "Angepasstes Feld 5", + "ContractCustom6": "Angepasstes Feld 6", + "ContractCustom7": "Angepasstes Feld 7", + "ContractCustom8": "Angepasstes Feld 8", + "ContractCustom9": "Angepasstes Feld 9", + "ContractDiscountParts": "Rabatt auf alle Teile angewendet", + "ContractList": "Verträge", + "ContractName": "Vertragsname", + "ContractNotes": "Anmerkungen", + "ContractRateList": "Vertragssätze", + "ContractRatesRateID": "Sätze", + "CoordinateTypesDecimalDegrees": "Dezimalgrad (DDD,ddd°)", + "CoordinateTypesDegreesDecimalMinutes": "Grad Minuten (DDD° MM,mmm)", + "CoordinateTypesDegreesMinutesSeconds": "Grad Minuten Sekunden (DDD° MM' SS,sss')", + "CustomFieldKey": "Schlüssel für angepasstes Feld", + "DashboardDashboard": "Dashboard", + "DashboardNext": "Next", + "DashboardNotAssigned": "Not assigned", + "DashboardOverdue": "Overdue", + "DashboardReminders": "Reminders", + "DashboardScheduled": "Scheduled", + "DispatchZoneDescription": "Beschreibung", + "DispatchZoneList": "Zuweisungszonen", + "DispatchZoneName": "Zuweisungszone - Name", + "ErrorAutoIncrementNumberTooLow": "Fehler: Die neue Nummer muss mindestens {0} sein, um keine Konflikte mit vorhandenen Datensätzen zu verursachen", + "ErrorDBFetchError": "Datenbankfehler: Datensatz kann nicht abgerufen werden: {0}", + "ErrorDBForeignKeyViolation": "Dieses Objekt kann nicht gelöscht werden, weil es mit einem oder mehreren verwandten Objekten verknüpft ist", + "ErrorDBRecordModifiedExternally": "Datenbankfehler: Der Datensatz in Tabelle {0} wurde von Benutzer {1} geändert, nachdem Sie ihn geöffnet haben.\\r\\nDer Datensatz kann jetzt nicht aktualisiert werden. Sie müssen ihn schließen und wieder öffnen,\\r\\nbevor Sie ihn ändern oder löschen können.", + "ErrorDBSchemaMismatch": "Fehler: Für dieses Programm ist Datenbankversion {0} erforderlich; die Datenbank, die geöffnet werden soll, ist Version {1}.", + "ErrorGridFilterByOtherColumnNotSupported": "Das Filtern durch Vergleichen mit Werten in anderen Spalten ({0}) wird nicht unterstützt", + "ErrorLicenseExpired": "Die AyaNova-Lizenz ist abgelaufen. Bis ein gültiger Lizenzschlüssel eingegeben wurde, ist nur eine schreibgeschützte Verwendung für alle Benutzer möglich.\r\nLizenzen können unter www.ayanova.com schnell und einfach zu einem günstigen Preis gekauft werden.", + "ErrorLicenseWillExpire": "Warnung: diese Lizenz läuft ab am {0}", + "ErrorLiteDatabase": "Error: AyaNova Lite can only be used with a standalone FireBird database", + "ErrorDuplicateNameWarning": "Warning: There is an existing item in the database with the same name", + "ErrorDuplicateSerialWarning": "Warning: There is an existing item in the database with the same serial number", + "ErrorFieldLengthExceeded": "{0} darf {1} Zeichen nicht überschreiten", + "ErrorFieldLengthExceeded255": "{0} überschreitet den Grenzwert von 255 Zeichen", + "ErrorFieldLengthExceeded500": "{0} überschreitet den Grenzwert von 500 Zeichen", + "ErrorFieldValueNotBetween": "{0} ist nicht gültig; der Wert muss zwischen {1} und {2} liegen", + "ErrorFieldValueNotValid": "{0} ist nicht gültig", + "ErrorNameFetcherNotFound": "Name/Bool-Abrufer: Feld {0} in Tabelle {1} mit der Datensatz-ID {2} wurde nicht gefunden", + "ErrorNotChangeable": "Fehler: ein {0}-Objekt kann nicht geändert werden", + "ErrorNotDeleteable": "Fehler: ein {0}-Objekt kann nicht gelöscht werden", + "ErrorRequiredFieldEmpty": "{0} ist ein Pflichtfeld. Geben Sie einen Wert für {0} ein.", + "ErrorStartDateAfterEndDate": "Startdatum muss vor dem Stopp-/Enddatum liegen", + "ErrorSecurityAdministratorOnlyMessage": "Für den Zugriff auf diese Funktion müssen Sie als Administrator angemeldet sein.", + "ErrorSecurityNotAuthorizedToChange": "Fehler: Der Benutzer ist nicht berechtigt, ein {0}-Objekt zu ändern, oder das Objekt oder Feld, das geändert wird, ist schreibgeschützt", + "ErrorSecurityNotAuthorizedToCreate": "Fehler: Der aktuelle Benutzer ist nicht berechtigt, ein neues {0}-Objekt zu erstellen", + "ErrorSecurityNotAuthorizedToDelete": "Fehler: Der aktuelle Benutzer ist nicht berechtigt, ein {0}-Objekt zu löschen", + "ErrorSecurityNotAuthorizedToDeleteDefaultObject": "Fehler: Das standardmäßige {0}-Objekt kann nicht gelöscht werden", + "ErrorSecurityNotAuthorizedToRetrieve": "Fehler: Der aktuelle Benutzer ist nicht berechtigt, einen {0}-Datensatz zu öffnen", + "ErrorSecurityUserCapacity": "Es sind nicht genügend verfügbare Lizenzen vorhanden, um mit diesem Vorgang fortzufahren", + "ErrorTrialRestricted": "Im Testmodus können höchstens 30 Arbeitsaufträge verwendet werden. Sie müssen einen Arbeitsauftrag löschen, bevor ein neuer hinzugefügt werden kann. Lizenzen können unter www.ayanova.com schnell und einfach zu einem günstigen Preis gekauft werden.", + "ErrorUnableToOpenDocumentUrl": "Dokument kann nicht geöffnet werden", + "ErrorUnableToOpenEmailUrl": "E-Mail-Adresse kann nicht geöffnet werden", + "ErrorUnableToOpenWebUrl": "Webadresse kann nicht geöffnet werden", + "FormFieldDataTypesCurrency": "Geld", + "FormFieldDataTypesDateOnly": "Datum", + "FormFieldDataTypesDateTime": "Datum und Zeit", + "FormFieldDataTypesNumber": "Nummer", + "FormFieldDataTypesText": "Text", + "FormFieldDataTypesTimeOnly": "Zeit", + "FormFieldDataTypesTrueFalse": "Wahr/Falsch", + "GlobalAllowScheduleConflicts": "Planungskonflikte zulassen", + "GlobalAllowScheduleConflictsDescription": "Wenn der Benutzer, der Zeitpläne zuordnet, benachrichtigt werden möchte, wenn sich ein geplanter Benutzer überschneidet, sollte hier FALSCH festgelegt werden. Wenn sich Zeitpläne gewöhnlich überschneiden, sollte hier WAHR festgelegt werden.", + "GlobalCJKIndex": "CJK-Index verwenden", + "GlobalCJKIndexDescription": "WAHR nur festgelegt, wenn chinesische, japanische oder koreanische Zeichen in Felder und Bezeichnungen eingegeben werden", + "GlobalCoordinateStyle": "Koordinate - Anzeigestil", + "GlobalCoordinateStyleDescription": "Bestimmt, wie geografische Koordinaten angezeigt werden", + "GlobalDefaultLanguageDescription": "Sprache, die für alle lokalisierten Bezeichnungen festgelegt wird", + "GlobalDefaultLatitude": "Koordinate - Standardbreite - Halbkugel", + "GlobalDefaultLatitudeDescription": "Standardhalbkugel für neuen Höheneintrag", + "GlobalDefaultLongitude": "Koordinate - Standardlänge - Halbkugel", + "GlobalDefaultLongitudeDescription": "Standardhalbkugel für neuen Breiteneintrag", + "GlobalDefaultPartDisplayFormat": "Teil - Anzeigeformat", + "GlobalDefaultPartDisplayFormatDescription": "Legt das Format fest, wie Teile für die Auswahl angezeigt werden", + "GlobalDefaultScheduleableUserNameDisplayFormat": "Benutzername - Anzeigeformat", + "GlobalDefaultScheduleableUserNameDisplayFormatDescription": "Bestimmt das Format, in dem planbare Benutzer in den Dropdown-Auswahlfeldern angezeigt werden", + "GlobalDefaultServiceTemplateIDDescription": "Template used globally when no other more specific template is in effect", + "GlobalDefaultUnitNameDisplayFormat": "Einheit - Anzeigeformat", + "GlobalInventoryAdjustmentStartSeed": "Bestandsberichtigung - Anfangsnummer", + "GlobalInventoryAdjustmentStartSeedDescription": "Die Anfangsnummer für die Bestandsberichtigung muss größer als die vorhandenen verwendeten Nummern sein. Sobald eine Nummer eingegeben wurde, kann keine kleinere Nummer mehr eingegeben werden.", + "GlobalLaborSchedUserDfltTimeSpan": "Scheduled / Labor default minutes", + "GlobalLaborSchedUserDfltTimeSpanDescription": "Scheduled Users/Labor default time span for new records (minutes). 0 = off", + "GlobalMainGridAutoRefresh": "Auto-refresh main grids", + "GlobalMainGridAutoRefreshDescription": "Refresh main grid lists automatically every 5 minutes.", + "GlobalMaxFileSizeMB": "Maximum embedded file size", + "GlobalMaxFileSizeMBDescription": "Largest single file size in megabytes that can be stored embedded in the database", + "GlobalNotifySMTPAccount": "SMTP-Anmeldung", + "GlobalNotifySMTPAccountDescription": "Anmeldekonto für SMTP-E-Mail-Server", + "GlobalNotifySMTPFrom": "SMTP - Antwort-/Von-Adresse", + "GlobalNotifySMTPFromDescription": "Von-E-Mail-Konto (Antwortadresse), das beim Senden ausgehender Benachrichtigungen verwendet werden soll", + "GlobalNotifySMTPHost": "SMTP-Server", + "GlobalNotifySMTPHostDescription": "Internet (SMTP)-E-Mail-Server, mit dem ausgehende Benachrichtigungen gesendet werden", + "GlobalNotifySMTPPassword": "SMTP-Kennwort", + "GlobalNotifySMTPPasswordDescription": "Kennwort für SMTP-Anmeldekonto", + "GlobalPropertyCategoryDisplayStyle": "Anzeigestil", + "GlobalPurchaseOrderStartSeed": "Einkaufsaufträge - Anfangsnummer", + "GlobalPurchaseOrderStartSeedDescription": "Die Anfangsnummer der Einkaufsaufträge muss größer als die vorhandenen verwendeten Nummern sein. Sobald eine Nummer eingegeben wurde, kann keine kleinere Nummer mehr eingegeben werden.", + "GlobalQuoteNumberStartSeed": "Angebote - Anfangsnummer", + "GlobalQuoteNumberStartSeedDescription": "Die Anfangsnummer für Angebote muss größer als die vorhandenen verwendeten Nummern sein. Sobald eine Nummer eingegeben wurde, kann keine kleinere Nummer mehr eingegeben werden.", + "GlobalRentalStartSeed": "Mietanfangsnummer", + "GlobalRentalStartSeedDescription": "Die Mietanfangsnummer muss größer als die vorhandenen verwendeten Nummern sein. Sobald eine Nummer eingegeben wurde, kann keine kleinere Nummer mehr eingegeben werden.", + "GlobalSchedUserNonTodayStartTime": "Scheduled default time", + "GlobalSchedUserNonTodayStartTimeDescription": "Scheduled user default time for new records when choosing start date other than today", + "GlobalSignatureFooter": "Signature footer", + "GlobalSignatureFooterDescription": "Text displayed as footer below signature box", + "GlobalSignatureHeader": "Signature header", + "GlobalSignatureHeaderDescription": "Text displayed as header above signature box", + "GlobalSignatureTitle": "Signature title", + "GlobalSignatureTitleDescription": "Text displayed as title above signature area", + "GlobalSMTPEncryption": "SMTP Encryption", + "GlobalSMTPEncryptionDescription": "Encryption method to use with SMTP server. Valid values are 'TLS', 'SSL' or empty for no encryption.", + "GlobalSMTPRetry": "SMTP Retry deliveries", + "GlobalSMTPRetryDescription": "Don't remove SMTP / SMS notifications if unable to connect to SMTP server; retry them again on next notification processing until delivered", + "GlobalSpellCheckDescription": "Wenn WAHR festgelegt ist, werden alle Textfelder mit der internen Rechtschreibliste der ausgewählten Sprache verglichen. Bei dieser Einstellung dauert das Speichern eines Datensatzes länger.", + "GlobalTaxPartPurchaseID": "Einkaufssteuer für Teile - Standard", + "GlobalTaxPartPurchaseIDDescription": "Umsatzsteuer, die standardmäßig für Teile auf Einkaufsaufträgen verwendet wird", + "GlobalTaxPartSaleID": "Umsatzsteuer für Teile - Standard", + "GlobalTaxPartSaleIDDescription": "Umsatzsteuer, die standardmäßig für Teile auf Arbeitsaufträgen verwendet wird", + "GlobalTaxRateSaleID": "Umsatzsteuer für Service - Standard", + "GlobalTaxRateSaleIDDescription": "Umsatzsteuer, die standardmäßig für Services auf Arbeitsaufträgen verwendet wird", + "GlobalTravelDfltTimeSpan": "Travel default minutes", + "GlobalTravelDfltTimeSpanDescription": "Travel default time span for new records (minutes). 0 = off", + "GlobalUnitNameDisplayFormatsDescription": "Bestimmt das Format, in dem Einheiten in den Dropdown-Auswahlfeldern auf Servicearbeitsaufträgen, Angeboten und Wartung/Inspektion-Aufträgen angezeigt werden.", + "GlobalUseInventory": "Bestand verwenden", + "GlobalUseInventoryDescription": "Wenn FALSCH festgelegt ist, wird der Zugriff auf die Teileeingabe und die Auswahl von auf Arbeitsaufträgen verwendeten Teilen eingeschränkt. Wenn WAHR festgelegt ist, kann auf alle Bestandsfunktionen zugegriffen werden.", + "GlobalUseNotification": "Benachrichtigung verwenden", + "GlobalUseNotificationDescription": "Wird WAHR festgelegt, wird das Benachrichtigungssystem eingeschaltet. Wird FALSCH festgelegt, wird die gesamte Benachrichtigungsverarbeitung ausgeschaltet.", + "GlobalUseRegions": "Regionen verwenden", + "GlobalUseRegionsDescription": "Wenn WAHR festgelegt ist, können einer Region zugeordnete Benutzer nicht die Informationen über Benutzer anzeigen, die einer anderen Region zugeordnet sind", + "GlobalWorkorderCloseByAge": "Arbeitsauftrag - unbearbeitet seit (Minuten)", + "GlobalWorkorderCloseByAgeDescription": "Anzahl der Minuten nach Auftragserstellung, nach der er geschlossen werden sollte. Diese Zeitspanne wird beim Erstellen zum aktuellen Datum/Zeit zum Festlegen des \"Schließen bis\"-Datums automatisch addiert. Bei Nichtverwendung ist der Standardwert null.", + "GlobalWorkorderClosedStatus": "Workorder closed status", + "GlobalWorkorderClosedStatusDescription": "If a status is selected here, a work order will be set to this status automatically when closed by a user in AyaNova or AyaNovaWBI", + "GlobalWorkorderNumberStartSeed": "Servicearbeitsaufträge - Anfangsnummer", + "GlobalWorkorderNumberStartSeedDescription": "Die Anfangsnummer eines Servicearbeitsauftrags muss größer als die vorhandenen verwendeten Nummern sein. Sobald eine Nummer eingegeben wurde, kann keine kleinere Nummer mehr eingegeben werden.", + "GlobalWorkorderSummaryTemplate": "Arbeitsauftragspostenzusammenfassung - Vorlage", + "GlobalWorkorderSummaryTemplateDescription": "Bestimmt, welche Informationen eines Servicearbeitsauftragsposten auf dem Zeitplanbildschirm angezeigt werden.", + "GridFilterName": "Filter name", + "HeadOfficeAccountNumber": "Kontonummer", + "HeadOfficeContact": "Contact", + "HeadOfficeContactNotes": "Other contacts", + "HeadOfficeCustom0": "Angepasstes Feld 0", + "HeadOfficeCustom1": "Angepasstes Feld 1", + "HeadOfficeCustom2": "Angepasstes Feld 2", + "HeadOfficeCustom3": "Angepasstes Feld 3", + "HeadOfficeCustom4": "Angepasstes Feld 4", + "HeadOfficeCustom5": "Angepasstes Feld 5", + "HeadOfficeCustom6": "Angepasstes Feld 6", + "HeadOfficeCustom7": "Angepasstes Feld 7", + "HeadOfficeCustom8": "Angepasstes Feld 8", + "HeadOfficeCustom9": "Angepasstes Feld 9", + "HeadOfficeEmail": "Email", + "HeadOfficeList": "Hauptsitze", + "HeadOfficeName": "Hauptsitz - Name", + "HeadOfficeNotes": "Anmerkungen", + "HeadOfficePhone1": "Business", + "HeadOfficePhone2": "Fax", + "HeadOfficePhone3": "Home", + "HeadOfficePhone4": "Mobile", + "HeadOfficePhone5": "Pager", + "KeyNotFound": "In der Zwischenablage wurde kein Schlüssel gefunden", + "KeyNotValid": "Schlüssel konnte nicht validiert werden", + "KeySaved": "Der Schlüssel wurde gespeichert; starten Sie AyaNova auf allen Computern jetzt neu", + "LoanItemCurrentWorkorderItemLoan": "Aktueller Arbeitsauftragsposten - Leih-ID", + "LoanItemCustom0": "Angepasstes Feld 0", + "LoanItemCustom1": "Angepasstes Feld 1", + "LoanItemCustom2": "Angepasstes Feld 2", + "LoanItemCustom3": "Angepasstes Feld 3", + "LoanItemCustom4": "Angepasstes Feld 4", + "LoanItemCustom5": "Angepasstes Feld 5", + "LoanItemCustom6": "Angepasstes Feld 6", + "LoanItemCustom7": "Angepasstes Feld 7", + "LoanItemCustom8": "Angepasstes Feld 8", + "LoanItemCustom9": "Angepasstes Feld 9", + "LoanItemList": "Leihposten", + "LoanItemName": "Name", + "LoanItemNotes": "Anmerkungen", + "LoanItemRateDay": "Day rate", + "LoanItemRateHalfDay": "Half day rate", + "LoanItemRateHour": "Hour rate", + "LoanItemRateMonth": "Month rate", + "LoanItemRateNone": "-", + "LoanItemRateWeek": "Week rate", + "LoanItemRateYear": "Year rate", + "LoanItemSerial": "Seriennummer", + "LocaleCustomizeText": "Customize text", + "LocaleExport": "Gebietsschema in Datei exportieren", + "LocaleImport": "Gebietsschema aus Datei importieren", + "LocaleList": "Lokalisierte Textsammlung", + "LocaleLocaleFile": "AyaNova übertragbare Gebietsschemadatei (*.xml)", + "LocaleUIDestLocale": "Neuer Name für Gebietsschema", + "LocaleUISourceLocale": "Quellgebietsschema", + "LocaleWarnLocaleLocked": "Your user account is using the \"English\" locale text.\r\nThis locale is read only and can not be edited.\r\nPlease change your locale in your user settings to any other value than \"English\" to proceed.", + "LocalizedTextDisplayText": "Standardanzeigetext", + "LocalizedTextDisplayTextCustom": "Angepasster Anzeigetext", + "LocalizedTextKey": "Schlüssel", + "LocalizedTextLocale": "Sprache", + "MemoForward": "Weiterleiten", + "MemoReply": "Antworten", + "MemoEventCreated": "Memo - eingehend", + "MemoFromID": "Von", + "MemoList": "Memos", + "MemoMessage": "Nachricht", + "MemoRe": "AW:", + "MemoReplied": "Beantwortet", + "MemoSent": "Gesendet", + "MemoSentRelative": "Gesendet (relativ)", + "MemoSubject": "Betreff", + "MemoToID": "An", + "MemoViewed": "Angezeigt", + "NotifyNotificationMessage": "Nachricht", + "NotifySourceOfEvent": "Quelle", + "NotifyDeliveryLogDelivered": "Lieferung erfolgreich", + "NotifyDeliveryLogDeliveryDate": "Geliefert am", + "NotifyDeliveryLogErrorMessage": "Fehlermeldung", + "NotifyDeliveryLogList": "Benachrichtigungslieferungen (letzten 7 Tage)", + "NotifyDeliveryLogToUser": "Geliefert an", + "NotifyDeliveryMessageFormatsBrief": "Kurzes, kompaktes Format", + "NotifyDeliveryMessageFormatsFull": "Vollständiges Format", + "NotifyDeliveryMethodsMemo": "AyaNova-Memo", + "NotifyDeliveryMethodsPopUp": "Popup-Nachrichtenfeld", + "NotifyDeliveryMethodsSMS": "SMS-fähiges Gerät", + "NotifyDeliveryMethodsSMTP": "Internet-E-Mail-Konto", + "NotifyDeliverySettingAddress": "Adresse", + "NotifyDeliverySettingAllDay": "Gesamten Tag", + "NotifyDeliverySettingAnyTime": "Benachrichtigung jederzeit liefern", + "NotifyDeliverySettingDeliver": "Liefern", + "NotifyDeliverySettingDeliveryMethod": "Physikalische Liefermethode", + "NotifyDeliverySettingEndTime": "Endzeit", + "NotifyDeliverySettingEventWindows": "Benachrichtigungen nur zu diesen Zeiten liefern:", + "NotifyDeliverySettingList": "Benachrichtigungsliefermethoden", + "NotifyDeliverySettingMaxCharacters": "Maximale Zeichenanzahl", + "NotifyDeliverySettingMessageFormat": "Nachrichtenformat", + "NotifyDeliverySettingName": "Name", + "NotifyDeliverySettingStartTime": "Startzeit", + "NotifySubscriptionCreated": "Abonniert", + "NotifySubscriptionEventDescription": "Ereignis", + "NotifySubscriptionList": "Benachrichtigungsabonnements", + "NotifySubscriptionPendingSpan": "Vor Ereignis benachrichtigen", + "NotifySubscriptionWarningNoDeliveryMethod": "Mindestens eine Liefermethode für Benachrichtigungen ist erforderlich, um Benachrichtigungen abonnieren zu können Möchten Sie jetzt eine einrichten?", + "NotifySubscriptionDeliveryUIAddNew": "Liefermethode hinzufügen", + "Address": "Adresse", + "AssignedDoc": "Dokument", + "AyaFile": "Embedded file", + "Client": "Kunde", + "ClientGroup": "Kundengruppe", + "ClientNote": "Kundenanmerkung", + "ClientNoteType": "Kundenanmerkungstyp", + "ClientRequestPart": "Angefordertes Teil", + "ClientRequestTech": "Angeforderter planbarer Benutzer", + "ClientRequestWorkorder": "Angeforderter Arbeitsauftrag", + "ClientRequestWorkorderItem": "Angeforderter Arbeitsauftragsposten", + "ClientServiceRequest": "Kundenserviceanforderung", + "ClientServiceRequestItem": "Kundenserviceanforderung - Posten", + "Contact": "Ansprechpartner", + "ContactPhone": "Telefon des Ansprechpartners", + "ContactTitle": "Anrede des Ansprechpartners", + "Contract": "Vertrag", + "ContractPart": "Vertrag - Teil", + "ContractRate": "Vertragssatz", + "DispatchZone": "Zuweisungszone", + "Global": "Global", + "GlobalWikiPage": "Global Wiki page", + "GridFilter": "GridFilter", + "HeadOffice": "Hauptsitz", + "LoanItem": "Leihposten", + "Locale": "Gebietsschema", + "LocalizedText": "Lokalisierter Text", + "Maintenance": "AyaNova - Interne Wartung", + "Memo": "Memo", + "NameFetcher": "Namenabrufer-Objekt", + "Notification": "Benachrichtigung", + "NotifySubscription": "Benachrichtigungsabonnement", + "NotifySubscriptionDelivery": "Benachrichtigungsliefermethode", + "Part": "Teil", + "PartAssembly": "Teilebaugruppe", + "PartByWarehouseInventory": "Teil nach Lagerbestand", + "PartCategory": "Teilekategorie", + "PartInventoryAdjustment": "Teilebestandberichtigung", + "PartInventoryAdjustmentItem": "Teilebestandberichtigungsposten", + "PartSerial": "Serienteil", + "PartWarehouse": "Teilelager", + "PreventiveMaintenance": "Wartung/Inspektion", + "Priority": "Priorität", + "Project": "Projekt", + "PurchaseOrder": "Einkaufsauftrag", + "PurchaseOrderItem": "Einkaufsauftragsposten", + "PurchaseOrderReceipt": "Einkaufsauftragseingang", + "PurchaseOrderReceiptItem": "Einkaufsauftragseingang - Posten", + "Rate": "Satz", + "RateUnitChargeDescription": "Gebührenbeschreibung für Satzeinheit", + "Region": "Region", + "Rental": "Miete", + "RentalUnit": "Mieteinheit", + "Report": "Bericht", + "ScheduleableUserGroup": "Planbare Benutzergruppe", + "ScheduleableUserGroupUser": "Planbare Benutzergruppe - Benutzer", + "ScheduleForm": "Zeitplanformular", + "ScheduleMarker": "Zeitplanmarkierung", + "SecurityGroup": "Sicherheitsgruppe", + "ServiceBank": "Serviceguthaben", + "Task": "Aufgabe", + "TaskGroup": "Aufgabengruppe", + "TaskGroupTask": "Aufgabe einer Aufgabengruppe", + "TaxCode": "Steuercode", + "Unit": "Einheit", + "UnitMeterReading": "Einheitenzählerstand", + "UnitModel": "Einheitenmodell", + "UnitModelCategory": "Einheitenmodellkategorie", + "UnitOfMeasure": "Maßeinheit", + "UnitServiceType": "Einheitenservicetyp", + "User": "Benutzer", + "UserCertification": "Benutzerzertifizierung", + "UserCertificationAssigned": "Zugeordnete Benutzerzertifizierung", + "UserRight": "Benutzerrecht-Objekt", + "UserSkill": "Benutzerfähigkeit", + "UserSkillAssigned": "Zugeordnete Benutzerfähigkeit", + "Vendor": "Lieferant", + "WikiPage": "Wiki page", + "Workorder": "Arbeitsauftrag", + "WorkorderClose": "Close work order", + "WorkorderCategory": "Arbeitsauftragskategorie", + "WorkorderItem": "Arbeitsauftragsposten", + "WorkorderItemLabor": "Arbeitsauftragsposten - Arbeit", + "WorkorderItemLoan": "Arbeitsauftragsposten - Ausleihe", + "WorkorderItemMiscExpense": "Arbeitsauftragsposten - versch. Aufwendungen", + "WorkorderItemPart": "Arbeitsauftragsposten - Teil", + "WorkorderItemPartRequest": "Arbeitsauftragsposten - Teileanforderung", + "WorkorderItemScheduledUser": "Arbeitsauftragsposten - geplanter Benutzer", + "WorkorderItemTask": "Arbeitsauftragsposten - Aufgabe", + "WorkorderItemTravel": "Arbeitsauftragsposten - Reisen", + "WorkorderItemType": "Arbeitsauftragspostentyp", + "WorkorderItemUnit": "Workorder item unit", + "WorkorderPreventiveMaintenance": "Wartung/Inspektion", + "WorkorderPreventiveMaintenanceTemplate": "Preventive maintenance template", + "WorkorderQuote": "Angebot", + "WorkorderQuoteTemplate": "Quote template", + "WorkorderService": "Arbeitsauftrag", + "WorkorderServiceTemplate": "Service template", + "WorkorderStatus": "Arbeitsauftragsstatus", + "ObjectCustomFieldCustomGrid": "Anpassbare Felder", + "ObjectCustomFieldDisplayName": "Anzeigen als", + "ObjectCustomFieldFieldName": "Feldname", + "ObjectCustomFieldFieldType": "Felddatentyp", + "ObjectCustomFieldObjectName": "Objektname", + "ObjectCustomFieldVisible": "Sichtbar", + "OutsideServiceList": "Fremdleistungen - Liste", + "PartMustTrackSerial": "Für das Verfolgen von Seriennummern kann nicht FALSCH festgelegt werden, weil für dieses Teil ein Verlauf mit Seriennummer bereits aufgezeichnet ist", + "PartTrackSerialHasInventory": "Track serial numbers can not be turned on as this part still has items in inventory", + "PartAlert": "Warnungstext", + "PartAlternativeWholesalerID": "Alternativer Großhändler", + "PartAlternativeWholesalerNumber": "Alternativer Großhändler - Nummer", + "PartCustom0": "Angepasstes Feld 0", + "PartCustom1": "Angepasstes Feld 1", + "PartCustom2": "Angepasstes Feld 2", + "PartCustom3": "Angepasstes Feld 3", + "PartCustom4": "Angepasstes Feld 4", + "PartCustom5": "Angepasstes Feld 5", + "PartCustom6": "Angepasstes Feld 6", + "PartCustom7": "Angepasstes Feld 7", + "PartCustom8": "Angepasstes Feld 8", + "PartCustom9": "Angepasstes Feld 9", + "PartList": "Teile", + "PartManufacturerID": "Hersteller", + "PartManufacturerNumber": "Herstellernummer", + "PartName": "Teil - Name", + "PartNotes": "Anmerkungen", + "PartPartNumber": "Teilenummer", + "PartRetail": "Einzelhandel", + "PartTrackSerialNumber": "Seriennummer verfolgen", + "PartUPC": "EAN", + "PartWholesalerID": "Großhändler", + "PartWholesalerNumber": "Großhändlernummer", + "PartAssemblyDescription": "Beschreibung", + "PartAssemblyList": "Teilebaugruppen", + "PartAssemblyName": "Teilebaugruppe - Name", + "PartByWarehouseInventoryList": "Teilebestand", + "PartByWarehouseInventoryMinStockLevel": "Aufstockungsebene", + "PartByWarehouseInventoryQtyOnOrderCommitted": "Zugesagte bestellte Menge", + "PartByWarehouseInventoryQuantityOnHand": "Vorrätig", + "PartByWarehouseInventoryQuantityOnOrder": "Bestellt", + "PartByWarehouseInventoryReorderQuantity": "Nachbestellungsmenge", + "PartCategoryList": "Teilekategorien", + "PartCategoryName": "Teilekategorie - Name", + "PartDisplayFormatsAssemblyNumberName": "Baugruppe - Nummer - Name", + "PartDisplayFormatsCategoryNumberName": "Kategorie - Nummer - Name", + "PartDisplayFormatsManufacturerName": "Hersteller - Name", + "PartDisplayFormatsManufacturerNumber": "Hersteller - Nummer", + "PartDisplayFormatsName": "Nur Name", + "PartDisplayFormatsNameCategoryNumberManufacturer": "Name - category - number - manufacturer", + "PartDisplayFormatsNameNumber": "Name - Nummer", + "PartDisplayFormatsNameNumberManufacturer": "Name - number - manufacturer", + "PartDisplayFormatsNameUPC": "Name - EAN", + "PartDisplayFormatsNumber": "Nur Nummer", + "PartDisplayFormatsNumberName": "Nummer - Name", + "PartDisplayFormatsNumberNameManufacturer": "Nummer - Name - Hersteller", + "PartDisplayFormatsUPC": "Nur EAN", + "PartInventoryAdjustmentAdjustmentNumber": "Nummer", + "PartInventoryAdjustmentDateAdjusted": "Berichtigt am", + "PartInventoryAdjustmentPartInventoryAdjustmentID": "Berichtigungs-ID", + "PartInventoryAdjustmentReasonForAdjustment": "Grund", + "PartInventoryAdjustmentItemNegativeQuantityInvalid": "Es sind nicht genügend oder keine Teile dieses Typs in diesem Lager, um sie aus dem Bestand zu entfernen", + "PartInventoryAdjustmentItemPartNotUnique": "Die gleiche Teil/Lager-Kombination kann bei einer einzelnen Berichtigung nur einmal verwendet werden", + "PartInventoryAdjustmentItemZeroQuantityInvalid": "Eine Menge ist erforderlich", + "PartInventoryAdjustmentItemQuantityAdjustment": "Mengenberichtigung", + "PartRestockRequiredByVendorList": "Teileaufstockung durch Lieferant erforderlich", + "PartSerialAdjustmentID": "Berichtigung", + "PartSerialAvailable": "Verfügbar", + "PartSerialDateConsumed": "Verbraucht", + "PartSerialDateReceived": "Empfangen", + "PartSerialSerialNumberNotUnique": "Für dieses Teil wurde bereits eine Seriennummer eingegeben", + "PartSerialWarehouseID": "Teilelager", + "PartWarehouseDescription": "Beschreibung", + "PartWarehouseList": "Teilelager", + "PartWarehouseName": "Teilelager - Name", + "PriorityColor": "Farbe", + "PriorityList": "Prioritäten", + "PriorityName": "Priorität - Name", + "ProjectAccountNumber": "Kontonummer", + "ProjectCustom0": "Angepasstes Feld 0", + "ProjectCustom1": "Angepasstes Feld 1", + "ProjectCustom2": "Angepasstes Feld 2", + "ProjectCustom3": "Angepasstes Feld 3", + "ProjectCustom4": "Angepasstes Feld 4", + "ProjectCustom5": "Angepasstes Feld 5", + "ProjectCustom6": "Angepasstes Feld 6", + "ProjectCustom7": "Angepasstes Feld 7", + "ProjectCustom8": "Angepasstes Feld 8", + "ProjectCustom9": "Angepasstes Feld 9", + "ProjectDateCompleted": "Abgeschlossen am", + "ProjectDateStarted": "Begonnen am", + "ProjectList": "Projekte", + "ProjectName": "Projektname", + "ProjectNotes": "Anmerkungen", + "ProjectProjectOverseerID": "Projektleiter", + "PurchaseOrderActualReceiveDate": "Fällig am", + "PurchaseOrderCustom0": "Angepasstes Feld 0", + "PurchaseOrderCustom1": "Angepasstes Feld 1", + "PurchaseOrderCustom2": "Angepasstes Feld 2", + "PurchaseOrderCustom3": "Angepasstes Feld 3", + "PurchaseOrderCustom4": "Angepasstes Feld 4", + "PurchaseOrderCustom5": "Angepasstes Feld 5", + "PurchaseOrderCustom6": "Angepasstes Feld 6", + "PurchaseOrderCustom7": "Angepasstes Feld 7", + "PurchaseOrderCustom8": "Angepasstes Feld 8", + "PurchaseOrderCustom9": "Angepasstes Feld 9", + "PurchaseOrderDropShipToClientID": "Streckengeschäft an Kunden", + "PurchaseOrderLocked": "Einkaufsauftrag ist aufgrund seines Status gesperrt", + "PurchaseOrderExpectedReceiveDate": "Erwartet", + "PurchaseOrderNotes": "Anmerkungen", + "PurchaseOrderOrderedDate": "Bestellt am", + "PurchaseOrderPONumber": "EA-Nummer", + "PurchaseOrderStatusClosedFullReceived": "Geschlossen - vollständig empfangen", + "PurchaseOrderStatusClosedNoneReceived": "Geschlossen - nichts empfangen", + "PurchaseOrderStatusClosedPartialReceived": "Geschlossen - teilweise empfangen", + "PurchaseOrderStatusOpenNotYetOrdered": "Offen - noch nicht bestellt", + "PurchaseOrderStatusOpenOrdered": "Offen - bestellt", + "PurchaseOrderStatusOpenPartialReceived": "Offen - teilweise empfangen", + "PurchaseOrderReferenceNumber": "Referenznummer", + "PurchaseOrderShowPartsAllVendors": "Select from any vendor's part", + "PurchaseOrderStatus": "Einkaufsauftragsstatus", + "PurchaseOrderUICopyToPurchaseOrder": "Auf EA kopieren", + "PurchaseOrderUINoPartsForVendorWarning": "Für den geplanten Lieferanten sind in AyaNova keine Teile definiert. Sie können keine Einkaufsauftragsposten für diesen Lieferanten eingeben.", + "PurchaseOrderUIOrderedWarning": "Möchten Sie für diesen Einkaufsauftrag wirklich den Status \"Bestellt\" festlegen?", + "PurchaseOrderUIRestockList": "Aufstockungsliste", + "PurchaseOrderVendorMemo": "Lieferant - Memo", + "PurchaseOrderItemClosed": "Geschlossen", + "PurchaseOrderItemLineTotal": "Zeilensumme", + "PurchaseOrderItemNetTotal": "Netto - Gesamt", + "PurchaseOrderItemPartName": "Teil - Name", + "PurchaseOrderItemPartNumber": "Teilenummer", + "PurchaseOrderItemPartRequestedByID": "Angefordert von", + "PurchaseOrderItemPurchaseOrderCost": "EA-Kosten", + "PurchaseOrderItemQuantityOrdered": "Bestellte Menge", + "PurchaseOrderItemQuantityReceived": "Empfangene Menge", + "PurchaseOrderItemUIOrderedFrom": "Bestellt von", + "PurchaseOrderItemUISaveWarning": "Möchten Sie wirklich speichern? Sobald dieser Datensatz gespeichert ist, wird er gesperrt und kann nicht mehr bearbeitet werden.", + "PurchaseOrderItemWorkorderNumber": "Arbeitsauftragsnummer", + "PurchaseOrderReceiptItems": "Einkaufsauftragseingang - Posten", + "PurchaseOrderReceiptPartRequestNotFound": "Teileanforderung nicht gefunden", + "PurchaseOrderReceiptReceivedDate": "Empfangen am", + "PurchaseOrderReceiptText1": "Text1", + "PurchaseOrderReceiptText2": "Text2", + "PurchaseOrderReceiptItemPONumber": "Einkaufsauftrag", + "PurchaseOrderReceiptItemPurchaseOrderItemID": "Einkaufsauftragsposten", + "PurchaseOrderReceiptItemPurchaseOrderReceiptID": "Einkaufsauftragseingang", + "PurchaseOrderReceiptItemQuantityReceived": "Empfangene Menge", + "PurchaseOrderReceiptItemQuantityReceivedErrorInvalid": "Der Eintrag muss gleich oder kleiner dem ausstehenden Betrag vom Einkaufsauftrag oder positiv sein", + "PurchaseOrderReceiptItemReceiptCost": "Ist-Kosten", + "PurchaseOrderReceiptItemReferenceNumber": "Referenz", + "PurchaseOrderReceiptItemWarehouseID": "Teilelager", + "PurchaseOrderReceiptItemWorkorderNumber": "Arbeitsauftragsnummer", + "RateAccountNumber": "Kontonummer", + "RateCharge": "Einzelhandelsgebühr", + "RateClientGroupID": "Kundengruppe", + "RateContractRate": "Vertragssatz", + "RateDescription": "Beschreibung", + "RateList": "Sätze", + "RateName": "Satzname", + "RateRateType": "Satztyp", + "RateRateTypeRental": "Miete", + "RateRateTypeService": "Service", + "RateRateTypeTravel": "Reisen", + "RateRateUnitChargeDescriptionID": "Einheitengebühr - Beschreibung", + "RateUnitChargeDescriptionList": "Satzeinheiten", + "RateUnitChargeDescriptionName": "Gebührenbeschreibung für Satzeinheit - Name", + "RateUnitChargeDescriptionNamePlural": "Plural name", + "RegionAttachQuote": "Attach quote report", + "RegionAttachWorkorder": "Attach workorder report", + "RegionClientNotifyMessage": "Message to send to client", + "RegionCSRAccepted": "CSR accepted", + "RegionCSRRejected": "CSR rejected", + "RegionDefaultPurchaseOrderTemplate": "Einkaufsauftragsvorlage - Standard", + "RegionDefaultQuoteTemplate": "Angebotsvorlage - Standard", + "RegionDefaultWorkorderTemplate": "Arbeitsauftragsvorlage - Standard", + "RegionFollowUpDays": "Days after work order closed", + "RegionList": "Regionen", + "RegionName": "Region - Name", + "RegionNewWO": "New WO", + "RegionQuoteStatusChanged": "Quote status changed", + "RegionReplyToEmailAddress": "Reply to email address", + "RegionWBIUrl": "AyaNova WBI url address", + "RegionWOClosedEmailed": "WO Closed", + "RegionWOFollowUp": "WO follow up", + "RegionWorkorderClosedStatus": "Arbeitsauftrag - geschlossen", + "RegionWOStatusChanged": "WO status changed", + "ReportActive": "Aktiv", + "ReportDesignReport": "Design", + "ReportImportDuplicate": "Ausgewählter Bericht kann nicht importiert werden: In Ihrer Datenbank ist bereits ein Bericht mit der gleichen internen ID vorhanden", + "ReportExport": "Exportieren nach ...", + "ReportExportHTML": "HTML-Datei (*.html)", + "ReportExportPDF": "Acrobat-Datei (*.pdf)", + "ReportExportRTF": "Rich Text-Datei (*.rtf)", + "ReportExportTIFF": "TIFF-Datei (*.tif)", + "ReportExportTXT": "Textdatei (*.txt)", + "ReportExportXLS": "Excel-Datei (*.xls)", + "ReportExportLayout": "Vorlage exportieren", + "ReportExportLayoutFile": "AyaNova übertragbare Berichtsdatei (*.ayr)", + "ReportImportLayout": "Vorlage importieren", + "ReportList": "Berichtsvorlagen", + "ReportMaster": "Hauptbericht", + "ReportMasterWarning": "Hauptbericht - schreibgeschützt", + "ReportName": "Name", + "ReportNewDetailedReport": "Neue Vorlage für detaillierten Bericht", + "ReportNewSummaryReport": "Neue Vorlage für Zusammenfassungsbericht", + "ReportReportKey": "Schlüssel", + "ReportReportSize": "Größe (Byte)", + "ReportSaveAsDialogTitle": "Berichtsvorlage speichern unter ...", + "ReportSecurityGroupID": "Restrict to security group", + "ReportEditorControls": "Toolbox", + "ReportEditorExplorer": "Explorer", + "ReportEditorFields": "Felder", + "ReportEditorProperties": "Eigenschaften", + "ScheduleableUserGroupDescription": "Beschreibung", + "ScheduleableUserGroupList": "Planbare Benutzergruppen", + "ScheduleableUserGroupName": "Planbare Benutzergruppe - Name", + "ScheduleableUserGroupScheduleableUsers": "Planbare Benutzer", + "ScheduleableUserGroupUserScheduleableUserGroupID": "Planbare Benutzergruppe - ID", + "ScheduleableUserGroupUserScheduleableUserID": "Planbarer Benutzer", + "ScheduleableUserNameDisplayFormatsEmployeeNumberFirstLast": "Arbeitnehmernummer - Vorname Nachname", + "ScheduleableUserNameDisplayFormatsEmployeeNumberInitials": "Arbeitnehmernummer - Initialien", + "ScheduleableUserNameDisplayFormatsFirstLast": "Vorname Nachname", + "ScheduleableUserNameDisplayFormatsFirstLastRegion": "First Last - Region", + "ScheduleableUserNameDisplayFormatsInitials": "Initialien", + "ScheduleableUserNameDisplayFormatsLastFirst": "Nachname, Vorname", + "ScheduleableUserNameDisplayFormatsLastFirstRegion": "Last, First - Region", + "ScheduleableUserNameDisplayFormatsRegionFirstLast": "Region - First Last", + "ScheduleableUserNameDisplayFormatsRegionLastFirst": "Region - Last, First", + "ScheduledList": "Geplant", + "ScheduleMarkerARGB": "ARGB", + "ScheduleMarkerColor": "Farbe", + "ScheduleMarkerCompleted": "Completed", + "ScheduleMarkerEventCreated": "Zeitplanmarkierung - gerade erstellt", + "ScheduleMarkerEventPendingAlert": "Zeitplanmarkierung - Ereignis steht unmittelbar bevor", + "ScheduleMarkerFollowUp": "Follow up", + "ScheduleMarkerList": "Schedule markers", + "ScheduleMarkerName": "Name", + "ScheduleMarkerNotes": "Anmerkungen", + "ScheduleMarkerRecurrence": "Wiederholung", + "ScheduleMarkerScheduleMarkerSourceType": "Für", + "ScheduleMarkerSourceID": "Quelle", + "ScheduleMarkerStartDate": "Start", + "ScheduleMarkerStopDate": "Ende", + "SearchResultDescription": "Beschreibung", + "SearchResultExtract": "Extrahieren", + "SearchResultRank": "Rang", + "SearchResultSource": "Quelle", + "SecurityGroupList": "Sicherheitsgruppen", + "SecurityGroupName": "Sicherheitsgruppe - Name", + "SecurityLevelTypesNoAccess": "Verboten", + "SecurityLevelTypesReadOnly": "Schreibgeschützt", + "SecurityLevelTypesReadWrite": "Lesen/Schreiben", + "SecurityLevelTypesReadWriteDelete": "Lesen/Schreiben/Löschen", + "ServiceBankAppliesToRootObjectID": "Gilt für Stammobjekt", + "ServiceBankAppliesToRootObjectType": "Gilt für Stammobjekttyp", + "ServiceBankCreated": "Eingegeben", + "ServiceBankCreator": "Benutzer", + "ServiceBankCurrency": "Währung", + "ServiceBankCurrencyBalance": "Währungssaldo", + "ServiceBankDescription": "Beschreibung", + "ServiceBankEffectiveDate": "Stichtag", + "ServiceBankEventCurrencyBalanceZero": "Serviceguthaben - Währung abgeschrieben", + "ServiceBankEventHoursBalanceZero": "Serviceguthaben - Stunden abgeschrieben", + "ServiceBankEventIncidentsBalanceZero": "Serviceguthaben - Vorkommnisse abgeschrieben", + "ServiceBankHours": "Stunden", + "ServiceBankHoursBalance": "Stundensaldo", + "ServiceBankID": "ID", + "ServiceBankIncidents": "Vorkommnisse", + "ServiceBankIncidentsBalance": "Vorkommnissaldo", + "ServiceBankList": "Serviceguthabenliste", + "ServiceBankSourceRootObjectID": "Quellen-ID", + "ServiceBankSourceRootObjectType": "Quelle", + "StopWords1": "sie sind soll sollen sollst sollt sonst soweit sowie und unser unsere unter vom von vor wann warum was weiter weitere wenn wer werde werden werdet weshalb wie wieder wieso wir wird wirst wo woher wohin zu zum zur über", + "StopWords2": "aber als am an auch auf aus bei bin bis bist da dadurch daher darum das daß dass dein deine dem den der des dessen deshalb die dies dieser dieses doch dort du durch ein eine einem einen einer eines er es euer eure für hatte hatten", + "StopWords3": "hattest hattet hier hinter ich ihr ihre im in ist ja jede jedem jeden jeder jedes jener jenes jetzt kann kannst können könnt machen mein meine mit muß mußt musst müssen müßt nach nachdem nein nicht nun oder seid sein seine sich", + "StopWords4": "?", + "StopWords5": "?", + "StopWords6": "?", + "StopWords7": "?", + "TaskList": "Aufgaben", + "TaskName": "Aufgabenname", + "TaskGroupDescription": "Beschreibung", + "TaskGroupList": "Aufgabengruppen", + "TaskGroupName": "Aufgabengruppe - Name", + "TaskGroupTaskTaskGroupID": "Aufgabengruppe", + "TaxCodeDefault": "Fehler: Weil dieser Steuercode in den globalen Einstellungen ein Standardwert ist, kann er nicht gelöscht oder deaktiviert werden", + "TaxCodeList": "Steuercodes", + "TaxCodeName": "Steuercode - Name", + "TaxCodeNotes": "Anmerkungen", + "TaxCodeTaxA": "Steuer \"A\"", + "TaxCodeTaxAExempt": "Steuer \"A\" befreit", + "TaxCodeTaxAValue": "Steuer A - Wert", + "TaxCodeTaxB": "Steuer \"B\"", + "TaxCodeTaxBExempt": "Steuer \"B\" befreit", + "TaxCodeTaxBValue": "Steuer B - Wert", + "TaxCodeTaxOnTax": "Steuer auf Steuer", + "Add": "Hinzufügen", + "Cancel": "Abbrechen", + "Close": "Beenden", + "Closed": "Geschlossen-Status ändern", + "CurrentDateAndTime": "Aktuelles Datum und Zeit", + "CustomFieldDesign": "Design für angepasstes Feld", + "Delete": "Löschen", + "Duplicate": "Duplizieren", + "Edit": "Edit", + "ExternalTools": "External tools", + "LocalizedTextDesign": "Design für lokalisierten Text", + "OK": "OK", + "Open": "Öffnen", + "Ordered": "Bestellt", + "Paste": "Einfügen", + "RecordHistory": "Datensatzverlauf", + "Save": "Speichern", + "SaveClose": "Speichern und beenden", + "SaveNew": "Speichern und neu", + "Search": "Suchen", + "ServiceHistory": "Serviceverlauf", + "Administration": "Verwaltung", + "AdministrationGlobalSettings": "Globale Einstellungen", + "Home": "Home", + "Inventory": "Bestand", + "InventoryPartInventoryAdjustments": "Berichtigungen", + "InventoryPartInventoryAdjustmentsDetailed": "Items", + "InventoryPurchaseOrderReceipts": "Einkaufsauftragseingänge", + "InventoryPurchaseOrderReceiptsReceive": "Bestand empfangen", + "InventoryPurchaseOrderReceiptsDetailed": "Items", + "InventoryPurchaseOrders": "Einkaufsaufträge", + "InventoryPurchaseOrdersDetailed": "Items", + "Logout": "Abmelden", + "Quotes": "Angebote", + "Schedule": "Zeitplan", + "Service": "Service", + "ServicePreventiveMaintenance": "W/I", + "ServiceQuotes": "Angebote", + "UnitModels": "Einheitenmodelle", + "UserMail": "Post", + "UserPreferences": "Benutzervoreinstellungen", + "UserPunchClock": "Benutzerstechuhr", + "VendorsSubContractors": "Subunternehmer", + "VendorsWholesalers": "Großhändler", + "GridFilterDialogAddConditionButtonText": "&Bedingung hinzufügen", + "GridFilterDialogAndRadioText": "Und-Bedingungen", + "GridFilterDialogCancelButtonText": "Abbre&chen", + "GridFilterDialogDeleteButtonText": "Bedingung löschen", + "GridFilterDialogOkButtonNoFiltersText": "Keine &Filter", + "GridFilterDialogOkButtonText": "&OK", + "GridFilterDialogOrRadioText": "Oder-Bedingungen", + "GridRowFilterDialogBlanksItem": "(Leere)", + "GridRowFilterDialogDBNullItem": "(DBNull)", + "GridRowFilterDialogEmptyTextItem": "(Leerer Text)", + "GridRowFilterDialogOperandHeaderCaption": "Operand", + "GridRowFilterDialogOperatorHeaderCaption": "Operator", + "GridRowFilterDialogTitlePrefix": "Geben Sie Filterkriterien ein für", + "GridRowFilterDropDownAllItem": "(Alle)", + "GridRowFilterDropDownBlanksItem": "(Leere)", + "GridRowFilterDropDownCustomItem": "(Angepasst)", + "GridRowFilterDropDownEquals": "Gleich", + "GridRowFilterDropDownGreaterThan": "Größer als", + "GridRowFilterDropDownGreaterThanOrEqualTo": "Größer als oder gleich", + "GridRowFilterDropDownLessThan": "Kleiner als", + "GridRowFilterDropDownLessThanOrEqualTo": "Kleiner als oder gleich", + "GridRowFilterDropDownLike": "Wie", + "GridRowFilterDropDownMatch": "Stimmt mit regulärem Ausdruck überein", + "GridRowFilterDropDownNonBlanksItem": "(Nicht leere)", + "GridRowFilterDropDownNotEquals": "Nicht gleich", + "GridRowFilterRegexError": "Fehler beim Analysieren des regulären Ausdrucks {0}. Geben Sie einen gültigen regulären Ausdruck ein.", + "GridRowFilterRegexErrorCaption": "Ungültiger regulärer Ausdruck", + "HelpAboutAyaNova": "Über AyaNova", + "HelpCheckForUpdates": "Nach Aktualisierungen suchen", + "HelpContents": "&Inhalt ...", + "HelpLicense": "License", + "HelpPurchaseLicenses": "Lizenzen kaufen", + "HelpTechSupport": "Technische Unterstützung", + "AllDay": "All day", + "AnyUser": "All users", + "ComboMoreRecordsPrompt": "< Weitere ... >", + "CopyOfText": "Kopie von", + "DateRange14DayWindow": "Fenster - 14 Tage", + "DateRangeFuture": "Future", + "DateRangeInTheLastSixMonths": "In the last 6 months", + "DateRangeInTheLastThreeMonths": "In the last 3 months", + "DateRangeInTheLastYear": "In the last year", + "DateRangeLastMonth": "Monat - letzter", + "DateRangeLastWeek": "Week - Previous", + "DateRangeLastYear": "Year - Last", + "DateRangeNextMonth": "Monat - nächster", + "DateRangeNextWeek": "Woche - nächste", + "DateRangePast": "Past", + "DateRangeThisMonth": "Monat - aktueller", + "DateRangeThisWeek": "Woche - aktuelle", + "DateRangeThisYear": "Year - Current", + "DateRangeToday": "Heute", + "DateRangeTomorrow": "Morgen", + "DateRangeYesterday": "Yesterday", + "DayAny": "Beliebiger Wochentag", + "DayFriday": "Freitag", + "DayMonday": "Montag", + "DaySaturday": "Samstag", + "DaySunday": "Sonntag", + "DayThursday": "Donnerstag", + "DayTuesday": "Dienstag", + "DayWednesday": "Mittwoch", + "DayOfWeek": "Wochentag", + "DeletePrompt": "Möchten Sie diesen Datensatz wirklich dauerhaft löschen?", + "DeleteWorkorderPrompt": "Are you sure you want to delete this Workorder permanently?", + "East": "Osten", + "FilterAvailableTo": "Available to", + "Filtered": "Gefiltert", + "FilterNone": "No filter", + "FilterUnsaved": "Unsaved filter", + "Find": "Suchen", + "FindAndReplace": "Suchen und ersetzen", + "FirstRows100": "First 100 rows", + "FirstRows1000": "First 1000 rows", + "FirstRows500": "First 500 rows", + "FirstRowsAll": "All rows", + "LastServiceDate": "Datum des zuletzt geschlossenen Service", + "LastWorkorder": "Zuletzt geschlossener Servicearbeitsauftrag", + "LineTotal": "Zeilensumme", + "NetValue": "Netto", + "North": "Norden", + "Object": "Objekt", + "Replace": "Ersetzen", + "SavePrompt": "Möchten Sie Änderungen speichern?", + "SelectItem": "Auswählen", + "SelectPurchaseOrdersToReceive": "Zu empfangende Einkaufsaufträge auswählen ...", + "SelectVendor": "Lieferant auswählen ...", + "SetLoginPassword": "Anmeldenamen und Kennwort festlegen", + "ShowAll": "Show all...", + "South": "Süden", + "SubTotal": "Zwischensumme", + "TimeSpanDays": "Tage", + "TimeSpanFutureRelative": "ab jetzt", + "TimeSpanHours": "Stunden", + "TimeSpanMinutes": "Minuten", + "TimeSpanMonths": "Monate", + "TimeSpanPastRelative": "vor", + "TimeSpanSeconds": "Sekunden", + "TimeSpanWeeks": "Wochen", + "TimeSpanWithinTheHour": "Within the hour", + "TimeSpanYears": "Jahre", + "Total": "Summe", + "UnsaveableDueToBrokenRulesPrompt": "Dieser Datensatz kann nicht gespeichert werden, weil er eine oder mehrere gebrochene Regeln hat. \r\n\r\nTrotzdem speichern?", + "West": "Westen", + "MenuGo": "Navigation", + "MenuHelp": "Hilfe", + "MenuMRU": "Recent...", + "MenuSubGrids": "Unterraster", + "RecordHistoryCreated": "Erstellungsdatum dieses Datensatzes", + "RecordHistoryCreator": "Ersteller dieses Datensatzes", + "RecordHistoryModified": "Änderungsdatum dieses Datensatzes", + "RecordHistoryModifier": "Datensatz wurde zuletzt geändert von", + "AddRemoveButtons": "Schaltflächen hinzufügen und entfernen ...", + "ClientsToolBar": "Kundensymbolleiste", + "Customize": "Anpassen ...", + "CustomizeDialog_AlwaysShowFullMenus": "Immer vollständige Menüs anzeigen", + "CustomizeDialog_CloseNoAmp": "Schließen", + "CustomizeDialog_FloatingToolbarFadeDelay": "Unverankerte Symbolleiste - Ausblendverzögerung", + "CustomizeDialog_KeyboardBeginAmp": "Tastatur ...", + "CustomizeDialog_LargeIconsOnMenus": "Große Symbole in Menüs", + "CustomizeDialog_LargeIconsOnToolbars": "Große Symbole auf Symbolleisten", + "CustomizeDialog_Milliseconds": "Millisekunden", + "CustomizeDialog_New": "Neu ...", + "CustomizeDialog_Other": "Weitere", + "CustomizeDialog_PersonalizedMenusAndToolbars": "Personenbezogene Menüs und Symbolleisten", + "CustomizeDialog_Rename": "Umbenennen ...", + "CustomizeDialog_ResetAmp": "Zurücksetzen ...", + "CustomizeDialog_ResetMyUsageData": "Eigene Verwendungsdaten zurücksetzen", + "CustomizeDialog_SelectedCommand": "Ausgewählter Befehl:", + "CustomizeDialog_ShowFullMenusAfterAShortDelay": "Vollständige Menüs nach einer kurzen Verzögerung anzeigen", + "CustomizeDialog_ShowScreenTipsOnToolbars": "QuickInfo auf Symbolleisten anzeigen", + "CustomizeDialog_ShowShortcutKeysInScreenTips": "Tastenkombinationen in QuickInfos anzeigen", + "CustomizeDlgKbdCat": "Kategorien:", + "CustomizeDlgKbdCmd": "Befehle", + "MainMenuBar": "Hauptmenüleiste", + "MainToolBar": "Hauptsymbolleiste", + "New": "Neu ...", + "PageSetup": "Page setup", + "Print": "Drucken", + "PrintPreview": "Print preview", + "Refresh": "Aktualisieren ...", + "ResetToolbar": "Symbolleiste zurücksetzen", + "ScheduleActiveWorkorderItem": "Aktiver Arbeitsauftragsposten:", + "ScheduleAddToActiveWorkorderItem": "Auswahl zum aktiven Arbeitsauftragsposten hinzufügen", + "ScheduleDayView": "1-Tag-Ansicht", + "ScheduleEditScheduleableUserGroup": "Planbare Benutzergruppen bearbeiten", + "ScheduleEditScheduleMarker": "Ausgewählte Zeitplanmarkierung bearbeiten", + "ScheduleEditWorkorder": "Ausgewählten Arbeitsauftrag bearbeiten", + "ScheduleMergeUsers": "Zusammen/Separat anzeigen", + "ScheduleMonthView": "Monatsansicht", + "ScheduleNewScheduleMarker": "Neue Zeitplanmarkierung", + "ScheduleNewWorkorder": "Neuer Servicearbeitsauftrag", + "SchedulePrintWorkorders": "Print selected work orders", + "ScheduleSelectScheduleableUserGroup": "Planbare Benutzer auswählen", + "ScheduleShowClosed": "Display Open / Closed (and open) work orders", + "ScheduleTimeLineView": "Display single day view as time line / regular", + "ScheduleToday": "Heute", + "ScheduleWeekView": "7-Tage-Wochenansicht", + "ScheduleWorkWeekView": "5-Tage-Arbeitswochenansicht", + "ScheduleToolBar": "Zeitplansymbolleiste", + "SecurityGroupFormSetAll": "Für alle Sicherheitsebenen die ausgewählte Ebene festlegen", + "WorkorderFormSetAllPartsUsedInService": "Für alle Teile \"Verwendet\" festlegen", + "UIGridLayoutDescription": "Rasterschlüssel - Beschreibung", + "UIGridLayoutGridKey": "Rasterschlüssel", + "UIGridLayoutLayoutContent": "Rasterlayoutdaten", + "UIGridLayoutLayoutSize": "Rasterlayoutdaten - Größe", + "UIGridLayoutObjectName": "UI-Rasterlayout-Objekt", + "UnitBoughtHere": "Hier gekauft", + "UnitCustom0": "Angepasstes Feld 0", + "UnitCustom1": "Angepasstes Feld 1", + "UnitCustom2": "Angepasstes Feld 2", + "UnitCustom3": "Angepasstes Feld 3", + "UnitCustom4": "Angepasstes Feld 4", + "UnitCustom5": "Angepasstes Feld 5", + "UnitCustom6": "Angepasstes Feld 6", + "UnitCustom7": "Angepasstes Feld 7", + "UnitCustom8": "Angepasstes Feld 8", + "UnitCustom9": "Angepasstes Feld 9", + "UnitDescription": "Beschreibung", + "UnitLastMeter": "Letzter Zählerstand", + "UnitLifeSpan": "Lebensspanne", + "UnitList": "Einheiten", + "UnitMetered": "Gemessene Einheit", + "UnitNotes": "Anmerkungen", + "UnitOverrideLength": "Länge überschreiben", + "UnitOverrideLifeTime": "Lebensdauer überschreiben", + "UnitOverrideWarranty": "Garantie überschreiben", + "UnitOverrideWarrantyExpiryDate": "Überschriebenes Garantieablaufdatum", + "UnitOverrideWarrantyTerms": "Garantiebedingungen überschreiben", + "UnitParentUnitID": "Übergeordnete Einheit dieser Einheit", + "UnitPurchasedDate": "Kaufdatum", + "UnitPurchaseFromID": "Gekauft von", + "UnitReceipt": "Eingangsnummer", + "UnitReplacedByUnitID": "Ersetzt von Einheit", + "UnitSerial": "Seriennummer", + "UnitText1": "Text1", + "UnitText2": "Text2", + "UnitText3": "Text3", + "UnitText4": "Text4", + "UnitUINotWarrantiedDisplay": "Keine Garantieleistung für Einheit", + "UnitUIWarrantiedDisplay": "Garantieleistung für Einheit bis {0}\r\n\r\nGarantiebedingungen: =-=-=-=-=-=-=-=- {1}", + "UnitUIWarrantyExpiredDisplay": "Garantie für Einheit lief ab am {0}", + "UnitUnitHasOwnAddress": "Einheit hat eigene Adresse", + "UnitWorkorderLastServicedID": "Zuletzt bearbeiteter Arbeitsauftrag", + "UnitMeterReadingDescription": "Zähler - Beschreibung", + "UnitMeterReadingList": "Einheitenzählerstand - Liste", + "UnitMeterReadingMeter": "Einheitenzählerstand", + "UnitMeterReadingMeterDate": "Zählerstand am", + "UnitMeterReadingWorkorderItemID": "Auf Arbeitsauftrag abgelesener Zähler", + "UnitModelCustom0": "Angepasstes Feld 0", + "UnitModelCustom1": "Angepasstes Feld 1", + "UnitModelCustom2": "Angepasstes Feld 2", + "UnitModelCustom3": "Angepasstes Feld 3", + "UnitModelCustom4": "Angepasstes Feld 4", + "UnitModelCustom5": "Angepasstes Feld 5", + "UnitModelCustom6": "Angepasstes Feld 6", + "UnitModelCustom7": "Angepasstes Feld 7", + "UnitModelCustom8": "Angepasstes Feld 8", + "UnitModelCustom9": "Angepasstes Feld 9", + "UnitModelDiscontinued": "Nicht mehr verfügbar", + "UnitModelDiscontinuedDate": "Nicht mehr verfügbar seit", + "UnitModelIntroducedDate": "Eingeführt am", + "UnitModelLifeTimeWarranty": "Lebenslange Garantie", + "UnitModelList": "Einheitenmodelle", + "UnitModelModelNumber": "Modellnummer", + "UnitModelName": "Einheitenmodell - Name", + "UnitModelNotes": "Anmerkungen", + "UnitModelUPC": "EAN", + "UnitModelVendorID": "Unit model vendor", + "UnitModelWarrantyLength": "Garantiedauer", + "UnitModelWarrantyTerms": "Garantiebedingungen", + "UnitModelCategoryDescription": "Beschreibung", + "UnitModelCategoryList": "Einheitenmodellkategorien", + "UnitModelCategoryName": "Einheitenmodellkategorie - Name", + "UnitNameDisplayFormatsModelModelNumberSerial": "Model name, model number, serial number", + "UnitNameDisplayFormatsModelNumberModelSerial": "Model number, model name, serial number", + "UnitNameDisplayFormatsModelSerial": "Modellnummer, Seriennummer", + "UnitNameDisplayFormatsSerialDescription": "Serial number, description", + "UnitNameDisplayFormatsSerialModel": "Seriennummer, Modellnummer", + "UnitNameDisplayFormatsSerialModelVendor": "Seriennummer, Modellnummer, Lieferant", + "UnitNameDisplayFormatsSerialOnly": "Seriennummer", + "UnitNameDisplayFormatsVendorModelModelNumberSerial": "Vendor, model name, model number, serial number", + "UnitNameDisplayFormatsVendorModelSerial": "Lieferant, Modellnummer, Seriennummer", + "UnitNameDisplayFormatsVendorSerial": "Lieferant - Seriennummer", + "UnitNameDisplayFormatsVendorSerialDescription": "Vendor, serial number, description", + "UnitOfMeasureList": "Maßeinheiten", + "UnitOfMeasureName": "Maßeinheit - Name", + "UnitServiceTypeDescription": "Beschreibung", + "UnitServiceTypeList": "Einheitenservicetypen", + "UnitServiceTypeName": "Name", + "UserCustom0": "Angepasstes Feld 0", + "UserCustom1": "Angepasstes Feld 1", + "UserCustom2": "Angepasstes Feld 2", + "UserCustom3": "Angepasstes Feld 3", + "UserCustom4": "Angepasstes Feld 4", + "UserCustom5": "Angepasstes Feld 5", + "UserCustom6": "Angepasstes Feld 6", + "UserCustom7": "Angepasstes Feld 7", + "UserCustom8": "Angepasstes Feld 8", + "UserCustom9": "Angepasstes Feld 9", + "UserDefaultWarehouseID": "Standardlager", + "UserEmailAddress": "Benutzer - E-Mail-Adresse", + "UserEmployeeNumber": "Arbeitnehmernummer", + "UserErrorNotSelectable": "Ausgewählter Benutzer ist nicht aktiv oder kein planbarer Benutzer", + "UserEventQuickNotification": "Quick Notification", + "UserFirstName": "Vorname", + "UserInitials": "Initialien", + "UserLastName": "Nachname", + "UserList": "Benutzer", + "UserLogin": "Anmeldename", + "UserMemberOfGroup": "Sicherheitsgruppe", + "UserMustBeActive": "This user must be active as it has open schedule items", + "UserMustBeScheduleable": "This user must be a Scheduleable User type to preserve data history", + "UserNotes": "Anmerkungen", + "UserPageAddress": "Pager-Adresse", + "UserPageMaxText": "Pager - max. Text", + "UserPassword": "Kennwort", + "UserPhone1": "Telefon 1", + "UserPhone2": "Telefon 2", + "UserScheduleBackColor": "Zeitplanhintergrundfarbe", + "UserStatus": "Status", + "UserSubContractor": "Ist ein Subunternehmer", + "UserTimeZoneOffset": "Override timezone", + "UserUIClearAllLayoutCustomization": "Alle Formularanpassungen des Benutzers löschen", + "UserUserCertifications": "Zertifizierungen", + "UserUserSkills": "Fähigkeiten", + "UserUserType": "Benutzertyp", + "UserVendorID": "Subunternehmer - Lieferant", + "UserCertificationDescription": "Beschreibung", + "UserCertificationList": "Benutzerzertifizierungen", + "UserCertificationName": "Benutzerzertifizierung - Name", + "UserCertificationAssignedValidStartDate": "Gültiges Startdatum", + "UserCertificationAssignedValidStopDate": "Gültiges Enddatum", + "UserRightList": "Mitgliedsrechte", + "UserRightRight": "Internes Objekt", + "UserRightSecurityLevel": "Sicherheitsebene", + "UserSkillDescription": "Beschreibung", + "UserSkillList": "Benutzerfähigkeiten", + "UserSkillName": "Benutzerfähigkeit - Name", + "UserTypesAdministrator": "Administrator", + "UserTypesClient": "Benutzer beim Kunden", + "UserTypesHeadOffice": "Benutzer am Hauptsitz des Kunden", + "UserTypesNonSchedulable": "Nicht planbarer Benutzer", + "UserTypesSchedulable": "Planbarer Benutzer", + "UserTypesUtilityNotification": "Benachrichtigungsserverkonto", + "VendorAccountNumber": "Kontonummer", + "VendorContact": "Contact", + "VendorContactNotes": "Other contacts", + "VendorCustom0": "Angepasstes Feld 0", + "VendorCustom1": "Angepasstes Feld 1", + "VendorCustom2": "Angepasstes Feld 2", + "VendorCustom3": "Angepasstes Feld 3", + "VendorCustom4": "Angepasstes Feld 4", + "VendorCustom5": "Angepasstes Feld 5", + "VendorCustom6": "Angepasstes Feld 6", + "VendorCustom7": "Angepasstes Feld 7", + "VendorCustom8": "Angepasstes Feld 8", + "VendorCustom9": "Angepasstes Feld 9", + "VendorEmail": "Email", + "VendorList": "Lieferanten", + "VendorName": "Lieferant - Name", + "VendorNotes": "Anmerkungen", + "VendorPhone1": "Business", + "VendorPhone2": "Fax", + "VendorPhone3": "Home", + "VendorPhone4": "Mobile", + "VendorPhone5": "Pager", + "VendorVendorType": "Lieferant - Typ", + "VendorVendorTypeManufacturer": "Hersteller", + "VendorVendorTypeShipper": "Transporteur", + "VendorVendorTypeSubContractor": "Subunternehmer", + "VendorVendorTypeThirdPartyRepair": "Drittanbieterreparatur", + "VendorVendorTypeWholesaler": "Großhändler", + "WikiPageInternalOnly": "Internal users only", + "WikiPageTitle": "Title", + "WorkorderClosed": "Geschlossen", + "WorkorderConvertScheduledUserToLabor": "Geplanten Benutzer in \"Arbeit\" kopieren", + "WorkorderCopyWorkorderItem": "Ausgewählten Arbeitsauftragsposten für diesen Kunden in einen vorhandenen Arbeitsauftrag kopieren", + "WorkorderMoveWorkorderItem": "Arbeitsauftragsposten auf anderen Arbeitsauftrag verschieben", + "WorkorderCustomerContactName": "Ansprechpartner", + "WorkorderCustomerReferenceNumber": "Kundenreferenznummer", + "WorkorderDeleted": "Gelöscht", + "WorkorderClosedIsPermanent": "Für einen geschlossenen Arbeitsauftrag kann niemals der Offen-Status festgelegt werden", + "WorkorderDeleteLastWorkorderItem": "Ein Arbeitsauftrag muss immer mindestens einen Arbeitsauftragsposten haben", + "WorkorderDirtyOrBrokenRules": "Dieser Vorgang kann nicht abgeschlossen werden – der Arbeitsauftrag wurde nicht gespeichert oder hat gebrochene Regeln", + "WorkorderLoanItemsNotReturned": "Dieser Vorgang kann nicht abgeschlossen werden. Ein oder mehrere verliehene Posten wurden noch nicht zurückgegeben.", + "WorkorderNotCloseableDueToErrors": "Dieser Arbeitsauftrag kann nicht geschlossen werden, weil eine oder mehrere Regeln gebrochen wurden", + "WorkorderNotCompleteableDueToErrors": "Für diesen Arbeitsauftrag kann nicht \"Service abgeschlossen\" festgelegt werden, weil eine oder mehrere Regeln gebrochen wurden", + "WorkorderPartRequestsOnOrder": "Dieser Vorgang kann nicht abgeschlossen werden. Ein oder mehrere angeforderte Teile sind bestellt und wurden noch nicht empfangen.", + "WorkorderPartRequestsUnOrdered": "This operation can not be completed - One or more unordered part requests need to be removed first", + "WorkorderSourceInvalidType": "Quellarbeitsauftragstyp ist nicht gültig", + "WorkorderEventCloseByDatePassed": "Arbeitsauftrag - \"Schließen bis\"-Datum verstrichen", + "WorkorderEventQuoteUpdated": "Quote - created / updated", + "WorkorderEventStatus": "Arbeitsauftrag - \"Status\" hat sich geändert", + "WorkorderFormLayoutID": "Formularlayout-ID", + "WorkorderFromPMID": "Übergeordnete W/I", + "WorkorderFromQuoteID": "Übergeordnetes Angebot", + "WorkorderGenerateUnit": "Generate unit from selected part", + "WorkorderInternalReferenceNumber": "Interne Referenznummer", + "WorkorderListAll": "List all work orders", + "WorkorderOnsite": "Vor Ort", + "WorkorderServiceCompleted": "Service abgeschlossen", + "WorkorderSign": "Sign", + "WorkorderSummary": "Zusammenfassung", + "WorkorderTemplate": "Vorlage", + "WorkorderTemplateDescription": "Template description", + "WorkorderTemplateFreshPrice": "Use current Part prices on generated order", + "WorkorderTemplateID": "Vorlagen-ID", + "WorkorderWarningClosedChanged": "Warnung: Wenn Sie fortfahren, wird der Arbeitsauftrag dauerhaft geschlossen. \r\n\r\nMöchten Sie wirklich fortfahren?", + "WorkorderWarningNotAllPartsUsed": "Warnung: Für ein oder mehrere Teile auf diesem Arbeitsauftrag wurde nicht der Verwendet-Status festgelegt.\r\n\r\nWenn Sie fortfahren, wird für alle Teile automatisch der Verwendet-Status festgelegt.\r\n\r\nMöchten Sie wirklich fortfahren?", + "WorkorderWarningServiceCompletedChanged": "Warnung: Wenn Sie fortfahren, wird dieses Formular geschlossen und der Status \"Service abgeschlossen\" des Arbeitsauftrags geändert. \r\n \r\nMöchten Sie wirklich fortfahren?", + "WorkorderWorkorderItems": "Arbeitsauftragsposten", + "WorkorderCategoryDescription": "Beschreibung", + "WorkorderCategoryList": "Arbeitsauftragskategorien", + "WorkorderCategoryName": "Arbeitsauftragskategorie - Name", + "WorkorderDetailsList": "Arbeitsauftragsdetails", + "WorkorderItemCustom0": "Angepasstes Feld 0", + "WorkorderItemCustom1": "Angepasstes Feld 1", + "WorkorderItemCustom2": "Angepasstes Feld 2", + "WorkorderItemCustom3": "Angepasstes Feld 3", + "WorkorderItemCustom4": "Angepasstes Feld 4", + "WorkorderItemCustom5": "Angepasstes Feld 5", + "WorkorderItemCustom6": "Angepasstes Feld 6", + "WorkorderItemCustom7": "Angepasstes Feld 7", + "WorkorderItemCustom8": "Angepasstes Feld 8", + "WorkorderItemCustom9": "Angepasstes Feld 9", + "WorkorderItemCustomFields": "Anpassbare Felder", + "WorkorderItemEventNotServiced": "Arbeitsauftragsposten - nicht schnell genug bedient", + "WorkorderItemExpenses": "Aufwendungen", + "WorkorderItemLabors": "Arbeit", + "WorkorderItemList": "Posten", + "WorkorderItemLoans": "Ausleihe", + "WorkorderItemOutsideService": "Fremdleistung", + "WorkorderItemPartRequests": "Teileanforderungen", + "WorkorderItemParts": "Teile", + "WorkorderItemPriorityID": "Priorität", + "WorkorderItemRequestDate": "Angefordert am", + "WorkorderItemScheduledUsers": "Geplante Benutzer", + "WorkorderItemSummary": "Postenzusammenfassung", + "WorkorderItemTaskListID": "Aufgabenliste", + "WorkorderItemTasks": "Aufgaben", + "WorkorderItemTechNotes": "Serviceanmerkungen", + "WorkorderItemTravels": "Reisen", + "WorkorderItemTypeID": "Arbeitsauftragspostentyp", + "WorkorderItemWarrantyService": "Garantieservice", + "WorkorderItemWorkorderStatusID": "Arbeitsauftragsposten - Status", + "WorkorderItemLaborLaborBanked": "Gutgeschrieben", + "WorkorderItemLaborLaborRateCharge": "Satzgebühr", + "WorkorderItemLaborList": "Arbeitsposten", + "WorkorderItemLaborNoChargeQuantity": "Ohne Gebühren - Menge", + "WorkorderItemLaborServiceDetails": "Servicedetails", + "WorkorderItemLaborServiceRateID": "Servicesatz", + "WorkorderItemLaborServiceRateQuantity": "Servicesatz - Menge", + "WorkorderItemLaborServiceStartDate": "Service - Startdatum und -zeit", + "WorkorderItemLaborServiceStopDate": "Service - Enddatum und -zeit", + "WorkorderItemLaborTaxCodeID": "Steuercode", + "WorkorderItemLaborTaxRateSaleID": "Umsatzsteuer", + "WorkorderItemLaborUIBankWarning": "Sind Sie sicher, dass Sie diesen Datensatz gutschreiben möchten? (Sobald dieser Datensatz gutgeschrieben ist, wird er gesperrt und kann nicht mehr bearbeitet werden.", + "WorkorderItemLaborUIReBankWarning": "Dieser Posten ist bereits gutgeschrieben", + "WorkorderItemLaborUserID": "Benutzer", + "WorkorderItemLoanCharges": "Gebühren", + "WorkorderItemLoanDueDate": "Rückgabe fällig am", + "WorkorderItemLoanList": "Leihposten", + "WorkorderItemLoanLoanItem": "Leihposten", + "WorkorderItemLoanLoanItemID": "Leihposten", + "WorkorderItemLoanLoanTaxA": "Steuer A", + "WorkorderItemLoanLoanTaxAExempt": "Steuer A befreit", + "WorkorderItemLoanLoanTaxB": "Steuer B", + "WorkorderItemLoanLoanTaxBExempt": "Steuer B befreit", + "WorkorderItemLoanLoanTaxOnTax": "Steuer auf Steuer", + "WorkorderItemLoanLoanTaxRateSale": "Steuer", + "WorkorderItemLoanNotes": "Anmerkungen", + "WorkorderItemLoanOutDate": "Ausgeliehen", + "WorkorderItemLoanQuantity": "Rate quantity", + "WorkorderItemLoanRate": "Rate", + "WorkorderItemLoanRateAmount": "Rate amount", + "WorkorderItemLoanReturnDate": "Zurückgegeben", + "WorkorderItemLoanTaxCodeID": "Umsatzsteuer", + "WorkorderItemMiscExpenseChargeAmount": "Gebühr - Betrag", + "WorkorderItemMiscExpenseChargeTaxCodeID": "Gebühr - Steuercode", + "WorkorderItemMiscExpenseChargeToClient": "Kunden belasten?", + "WorkorderItemMiscExpenseDescription": "Beschreibung", + "WorkorderItemMiscExpenseTaxA": "Versch. Aufw. St. A - Wert", + "WorkorderItemMiscExpenseTaxAExempt": "Versch. Aufw. St. A befreit", + "WorkorderItemMiscExpenseTaxB": "Versch. Aufw. St. B - Wert", + "WorkorderItemMiscExpenseTaxBExempt": "Versch. Aufw. St. B befreit", + "WorkorderItemMiscExpenseTaxOnTax": "Versch. Aufw. St. auf St.", + "WorkorderItemMiscExpenseTaxRateSale": "Versch. Aufw. Steuersatz", + "WorkorderItemMiscExpenseList": "Versch. Aufwendungen - Posten", + "WorkorderItemMiscExpenseName": "Versch. Aufw. - Zusammenfassung", + "WorkorderItemMiscExpenseReimburseUser": "Benutzer rückerstatten?", + "WorkorderItemMiscExpenseTaxPaid": "Gezahlte Steuern", + "WorkorderItemMiscExpenseTotalCost": "Gesamtkosten", + "WorkorderItemMiscExpenseUser": "Benutzer", + "WorkorderItemMiscExpenseUserID": "Benutzer", + "WorkorderItemOutsideServiceDateETA": "Vor. Ank.-Termin", + "WorkorderItemOutsideServiceDateReturned": "Zurückgegeben am", + "WorkorderItemOutsideServiceDateSent": "Gesendet am", + "WorkorderItemOutsideServiceEventUnitBackFromService": "Arbeitsauftragsposten - Fremdleistung - Einheit zurückerhalten", + "WorkorderItemOutsideServiceEventUnitNotBackFromServiceByETA": "Arbeitsauftragsposten - Fremdleistung - Einheit ist überfällig", + "WorkorderItemOutsideServiceNotes": "Anmerkungen", + "WorkorderItemOutsideServiceReceivedBack": "Zurückerhalten", + "WorkorderItemOutsideServiceRepairCost": "Reparaturkosten", + "WorkorderItemOutsideServiceRepairPrice": "Reparaturpreis", + "WorkorderItemOutsideServiceRMANumber": "RMA-Nummer", + "WorkorderItemOutsideServiceSenderUserID": "Versendet von", + "WorkorderItemOutsideServiceShippingCost": "Versandkosten", + "WorkorderItemOutsideServiceShippingPrice": "Versandpreis", + "WorkorderItemOutsideServiceTrackingNumber": "Verfolgungsnummer", + "WorkorderItemOutsideServiceVendorSentToID": "Gesendet an", + "WorkorderItemOutsideServiceVendorSentViaID": "Gesendet mit", + "WorkorderItemPartDescription": "Beschreibung", + "WorkorderItemPartDiscount": "Rabatt", + "WorkorderItemPartDiscountType": "Rabatttyp", + "WorkorderItemPartHasAffectedInventory": "Hat Bestände betroffen", + "WorkorderItemPartList": "Teileposten", + "WorkorderItemPartPartID": "Teil", + "WorkorderItemPartPartSerialID": "Seriennummer", + "WorkorderItemPartPartWarehouseID": "Lager", + "WorkorderItemPartPrice": "Preis", + "WorkorderItemPartQuantity": "Menge", + "WorkorderItemPartQuantityReserved": "Gewählte Menge", + "WorkorderItemPartTaxPartSaleID": "Umsatzsteuer", + "WorkorderItemPartUIQuantityReservedPM": "Erforderliche Menge", + "WorkorderItemPartUIQuantityReservedQuote": "Angebotene Menge", + "WorkorderItemPartUsed": "Beim Service verwendet", + "WorkorderItemPartWarningInsufficientStock": "Unzureichende Vorräte ({0:N}). Möchten Sie {1:N} anfordern?", + "WorkorderItemPartWarningPartNotFound": "Teil in Teileliste nicht gefunden", + "WorkorderItemPartRequestNotDeleteableOnOrder": "Eine Anforderung für ein Arbeitsauftragspostenteil kann nicht gelöscht werden, wenn Teile bestellt sind. Sobald die Teile empfangen wurden, kann sie gelöscht werden.", + "WorkorderItemPartRequestEventPartsReceived": "Arbeitsauftragsposten - Teileanforderung - Teile empfangen", + "WorkorderItemPartRequestList": "Teileanforderungen", + "WorkorderItemPartRequestOnOrder": "Bestellt", + "WorkorderItemPartRequestPartID": "Teil", + "WorkorderItemPartRequestPartWarehouseID": "Lager", + "WorkorderItemPartRequestQuantity": "Menge", + "WorkorderItemPartRequestReceived": "Empfangen", + "WorkorderItemScheduledUserRecordIncomplete": "Nichts zu planen", + "WorkorderItemScheduledUserEstimatedQuantity": "Geschätzte Menge", + "WorkorderItemScheduledUserEventCreatedUpdated": "Arbeitsauftragsposten - geplanter Benutzer (erstellt/aktualisiert)", + "WorkorderItemScheduledUserEventPendingAlert": "Arbeitsauftragsposten - geplanter Benutzer - Ereignis steht unmittelbar bevor", + "WorkorderItemScheduledUserList": "Geplante Benutzer - Posten", + "WorkorderItemScheduledUserServiceRateID": "Empfohlener Satz", + "WorkorderItemScheduledUserStartDate": "Startdatum und -zeit", + "WorkorderItemScheduledUserStartDateRelative": "Start (relativ)", + "WorkorderItemScheduledUserStopDate": "Enddatum und -zeit", + "WorkorderItemScheduledUserUserID": "Benutzer", + "WorkorderItemScheduledUserWarnOutOfRegion": "Warning: User is not in client's region - won't see this item", + "WorkorderItemTaskCompletionTypeComplete": "Abgeschlossen", + "WorkorderItemTaskCompletionTypeIncomplete": "Aufgabe", + "WorkorderItemTaskCompletionTypeNotApplicable": "n/v", + "WorkorderItemTaskObject": "Arbeitsauftragsposten - Aufgabe", + "WorkorderItemTaskTaskID": "Aufgabe", + "WorkorderItemTaskWorkorderItemTaskCompletionType": "Status", + "WorkorderItemTravelDistance": "Distanz", + "WorkorderItemTravelList": "Reiseposten", + "WorkorderItemTravelNoChargeQuantity": "Ohne Gebühren - Menge", + "WorkorderItemTravelNotes": "Anmerkungen", + "WorkorderItemTravelServiceRateID": "Reisesatz", + "WorkorderItemTravelTaxCodeID": "Steuercode", + "WorkorderItemTravelTaxRateSaleID": "Umsatzsteuer", + "WorkorderItemTravelDetails": "Reisedetails", + "WorkorderItemTravelRateCharge": "Reisesatzgebühr", + "WorkorderItemTravelRateID": "Reisesatz", + "WorkorderItemTravelRateQuantity": "Menge", + "WorkorderItemTravelStartDate": "Startdatum", + "WorkorderItemTravelStopDate": "Stoppdatum", + "WorkorderItemTravelUserID": "Benutzer", + "WorkorderItemTypeDescription": "Beschreibung", + "WorkorderItemTypeList": "Arbeitsauftragsposten - Typen", + "WorkorderItemTypeName": "Arbeitsauftragspostentyp - Name", + "WorkorderPreventiveMaintenanceDayOfTheWeek": "Gewünschter Wochentag", + "WorkorderPreventiveMaintenanceGenerateServiceWorkorder": "Servicearbeitsauftrag manuell erstellen", + "WorkorderPreventiveMaintenanceGenerateSpan": "Zeitspanne generieren", + "WorkorderPreventiveMaintenanceGenerateSpanUnit": "Generieren", + "WorkorderPreventiveMaintenanceList": "Wartung/Inspektion", + "WorkorderPreventiveMaintenanceNextServiceDate": "Nächster Service am", + "WorkorderPreventiveMaintenanceStopGeneratingDate": "Erstellung beenden am", + "WorkorderPreventiveMaintenanceThresholdSpan": "Schwellenwert für Zeitspanne", + "WorkorderPreventiveMaintenanceThresholdSpanUnit": "Schwellenwert", + "WorkorderPreventiveMaintenanceByUnitList": "Wartung/Inspektion nach Einheit", + "WorkorderQuoteDateApproved": "Genehmigt", + "WorkorderQuoteDateSubmitted": "Übermittelt", + "WorkorderQuoteGenerateServiceWorkorder": "Servicearbeitsauftrag von diesem Angebot generieren", + "WorkorderQuoteIntroduction": "Einleitungstext", + "WorkorderQuoteList": "Angebote", + "WorkorderQuotePreparedByID": "Vorbereitet von Benutzer", + "WorkorderQuoteQuoteNumber": "Angebotsnummer", + "WorkorderQuoteQuoteRequestDate": "Angefordert", + "WorkorderQuoteQuoteStatusType": "Status", + "WorkorderQuoteServiceWorkorderID": "Servicearbeitsauftrag", + "WorkorderQuoteValidUntilDate": "Gültig bis", + "WorkorderQuoteStatusTypesAwarded": "Erteilt", + "WorkorderQuoteStatusTypesInProgress": "In Bearbeitung", + "WorkorderQuoteStatusTypesNew": "New", + "WorkorderQuoteStatusTypesNotAwarded": "Nicht erteilt", + "WorkorderQuoteStatusTypesNotAwarded2": "Beyond economical repair", + "WorkorderQuoteStatusTypesSubmitted": "Übermittelt, warten ...", + "WorkorderServiceAge": "Age", + "WorkorderServiceClientRequestID": "Kundenanforderungsreferenz", + "WorkorderServiceCloseByDate": "\"Schließen bis\"-Datum", + "WorkorderServiceInvoiceNumber": "Rechnungsnummer", + "WorkorderServiceList": "Servicearbeitsaufträge", + "WorkorderServiceQuoteWorkorderID": "Angebot", + "WorkorderServiceServiceDate": "Servicedatum", + "WorkorderServiceServiceDateRelative": "Servicedatum (relativ)", + "WorkorderServiceServiceNumber": "Servicenummer", + "WorkorderServiceWorkorderPreventiveMaintenanceWorkorderID": "Wartung/Inspektion", + "WorkorderStatusARGB": "ARGB-Farbe", + "WorkorderStatusBold": "Fett", + "WorkorderStatusCompletedStatus": "Dieser Status ist \"Abgeschlossen\"", + "WorkorderStatusList": "Arbeitsauftragsstatusangaben", + "WorkorderStatusName": "Arbeitsauftragsstatus - Name", + "WorkorderStatusUnderlined": "Unterstrichen", + "WorkorderSummaryTemplate": "Arbeitsauftragspostenzusammenfassung - Vorlage", + "WorkorderSummaryWorkorderItem": "Anzuzeigende Informationen über Arbeitsauftragsposten" +} \ No newline at end of file diff --git a/server/AyaNova/resource/en.json b/server/AyaNova/resource/en.json new file mode 100644 index 00000000..677f8b48 --- /dev/null +++ b/server/AyaNova/resource/en.json @@ -0,0 +1,1415 @@ +{ + "AddressType": "Type of Address", + "AddressTypePhysical": "Physical Address", + "AddressTypePhysicalDescription": "This is the address where the physical building resides, where items are delivered", + "AddressTypePostal": "Postal Address", + "AddressTypePostalDescription": "This is the address where posted mail would be sent.", + "AddressCity": "City", + "AddressCopyToPhysical": "Copy to physical address", + "AddressCopyToPostal": "Copy to postal address", + "AddressCountry": "Country", + "AddressCountryCode": "Country code", + "AddressDeliveryAddress": "Street", + "AddressFullAddress": "Full Address", + "AddressLatitude": "Latitude", + "AddressLongitude": "Longitude", + "AddressMapQuestURL": "MapQuest map web link", + "AddressPostal": "Postal / ZIP Code", + "AddressPostalCity": "City (mail)", + "AddressPostalCountry": "Country (mail)", + "AddressPostalDeliveryAddress": "Address (mail)", + "AddressPostalPostal": "Postal / ZIP code (mail)", + "AddressPostalStateProv": "State / Province (mail)", + "AddressStateProv": "State or Province", + "AdminEraseDatabase": "Erase entire AyaNova database", + "AdminEraseDatabaseLastWarning": "Warning: This is your last chance to avoid erasing all the data permanently.\r\nAre you sure you want to erase all data?", + "AdminEraseDatabaseWarning": "Warning: you are about to permanently erase all data in AyaNova.\r\nAre you sure?", + "AdminPasteLicense": "Paste license key", + "AssignedDocDescription": "Description", + "AssignedDocList": "Documents", + "AssignedDocURL": "Document link", + "AyaFileFileTooLarge": "File size exceeds limit of {0}", + "AyaFileFileSize": "Size", + "AyaFileFileSizeStored": "Size stored", + "AyaFileFileType": "Type", + "AyaFileList": "Files in database", + "AyaFileSource": "Source", + "ClientAccountNumber": "Account Number", + "ClientBillHeadOffice": "Bill Head Office", + "ClientContact": "Contact", + "ClientContactNotes": "Other contacts", + "ClientCustom0": "My Custom0", + "ClientCustom1": "My Custom1", + "ClientCustom2": "My Custom2", + "ClientCustom3": "My Custom3", + "ClientCustom4": "My Custom4", + "ClientCustom5": "My Custom5", + "ClientCustom6": "My Custom6", + "ClientCustom7": "Custom field 7", + "ClientCustom8": "Custom field 8", + "ClientCustom9": "Custom field 9", + "ClientEmail": "Email", + "ClientEventContractExpire": "Client - contract expiring", + "ClientList": "Clients", + "ClientName": "Client name", + "ClientNotes": "General Notes", + "ClientNotification": "Send client notifications", + "ClientPhone1": "Business", + "ClientPhone2": "Fax", + "ClientPhone3": "Home", + "ClientPhone4": "Mobile", + "ClientPhone5": "Pager", + "ClientPopUpNotes": "Pop Up Notes", + "ClientTechNotes": "Scheduleable User Notes", + "ClientGroupDescription": "Description", + "ClientGroupList": "Client Groups", + "ClientGroupName": "Client Group Name", + "ClientNoteClientNoteTypeID": "Client Note Type", + "ClientNoteList": "Client Notes", + "ClientNoteNoteDate": "Note Date", + "ClientNoteNotes": "Notes", + "ClientNoteTypeList": "Client Note Types", + "ClientNoteTypeName": "Client Note Type Name", + "ClientRequestPartClientServiceRequestItemID": "Client service request item", + "ClientRequestPartPrice": "Price", + "ClientRequestPartQuantity": "Quantity", + "ClientRequestTechClientServiceRequestItemID": "Client service request item", + "ClientRequestTechScheduledStartDate": "Requested Scheduled Start Date", + "ClientRequestTechScheduledStopDate": "Requested Scheduled Stop Date", + "ClientRequestTechUserID": "Requested Scheduleable User", + "ClientServiceRequestAcceptToExisting": "Accept to existing work order", + "ClientServiceRequestAcceptToNew": "Accept to new work order", + "ClientServiceRequestCustomContactName": "Contact Name", + "ClientServiceRequestCustomerReferenceNumber": "Reference Number", + "ClientServiceRequestDetailedServiceToBePerformed": "Service to be performed details", + "ClientServiceRequestDetails": "Details", + "ClientServiceRequestEventCreated": "Client service request - New", + "ClientServiceRequestEventCreatedUpdated": "Client service request - new / updated", + "ClientServiceRequestList": "Customer service requests", + "ClientServiceRequestOnsite": "Onsite", + "ClientServiceRequestParts": "Parts", + "ClientServiceRequestPreferredTechs": "Requested scheduleable users", + "ClientServiceRequestPriority": "Priority", + "ClientServiceRequestReject": "Reject service request", + "ClientServiceRequestRequestedBy": "Requested by", + "ClientServiceRequestStatus": "Status", + "ClientServiceRequestTitle": "Title", + "ClientServiceRequestWorkorderItems": "Requested service Items", + "ClientServiceRequestItemServiceToBePerformed": "Service to be performed summary", + "ClientServiceRequestItemUnitID": "Unit", + "ClientServiceRequestPriorityASAP": "ASAP", + "ClientServiceRequestPriorityEmergency": "Emergency", + "ClientServiceRequestPriorityNotUrgent": "Not urgent", + "ClientServiceRequestStatusAccepted": "Accepted", + "ClientServiceRequestStatusClosed": "Closed", + "ClientServiceRequestStatusDeclined": "Declined", + "ClientServiceRequestStatusOpen": "Open", + "CommonActive": "Active", + "CommonContractExpires": "Contract expires", + "CommonCost": "Cost", + "CommonCreated": "Record Created", + "CommonCreator": "Record Created By", + "CommonDefaultLanguage": "Default language", + "CommonDescription": "Description", + "CommonID": "Unique identification number", + "CommonModified": "Record Last Modified", + "CommonModifier": "Record Last Modified by", + "CommonMore": "More...", + "CommonName": "Name", + "CommonRootObject": "Root object", + "CommonRootObjectType": "Root object type", + "CommonSerialNumber": "Serial Number", + "CommonUsesBanking": "Bank service", + "CommonWebAddress": "Web Address", + "ContactContactTitleID": "Title", + "ContactDescription": "Description", + "ContactEmailAddress": "Email Address", + "ContactFirstName": "First Name", + "ContactFullContact": "Full Contact", + "ContactJobTitle": "Job Title", + "ContactLastName": "Last Name", + "ContactPhones": "Phone Numbers", + "ContactPrimaryContact": "Primary Contact", + "ContactRootObjectID": "Root object ID", + "ContactRootObjectType": "Root object type", + "ContactPhoneContactID": "Contact", + "ContactPhoneType": "Contact Phone Type", + "ContactPhoneTypeBusiness": "Business", + "ContactPhoneTypeFax": "Fax", + "ContactPhoneTypeHome": "Home", + "ContactPhoneTypeMobile": "Mobile", + "ContactPhoneTypePager": "Pager", + "ContactPhoneFullPhoneRecord": "Full Phone", + "ContactPhoneAreaCode": "Area Code", + "ContactPhoneCountryCode": "Country Code", + "ContactPhoneDefault": "Default Phone", + "ContactPhoneExtension": "Extension", + "ContactPhoneNumber": "Phone Number", + "ContactPhoneTypeName": "Phone Type Name", + "ContactPhoneTypeObjectName": "Type", + "ContactTitleList": "Contact Titles", + "ContactTitleName": "Title Name", + "ContractContractRatesOnly": "Limit to contract rates only", + "ContractCustom0": "Custom field 0", + "ContractCustom1": "Custom field 1", + "ContractCustom2": "Custom field 2", + "ContractCustom3": "Custom field 3", + "ContractCustom4": "Custom field 4", + "ContractCustom5": "Custom field 5", + "ContractCustom6": "Custom field 6", + "ContractCustom7": "Custom field 7", + "ContractCustom8": "Custom field 8", + "ContractCustom9": "Custom field 9", + "ContractDiscountParts": "Discount Applied to All Parts", + "ContractList": "Contracts", + "ContractName": "Contract Name", + "ContractNotes": "Notes", + "ContractRateList": "Contract Rates", + "ContractRatesRateID": "Rates", + "CoordinateTypesDecimalDegrees": "Decimal degrees (DDD.ddd°)", + "CoordinateTypesDegreesDecimalMinutes": "Degrees minutes (DDD° MM.mmm)", + "CoordinateTypesDegreesMinutesSeconds": "Degrees Minutes Seconds (DDD° MM' SS.sss')", + "CustomFieldKey": "Custom field key", + "DashboardDashboard": "Dashboard", + "DashboardNext": "Next", + "DashboardNotAssigned": "Not assigned", + "DashboardOverdue": "Overdue", + "DashboardReminders": "Reminders", + "DashboardScheduled": "Scheduled", + "DispatchZoneDescription": "Description", + "DispatchZoneList": "Dispatch Zones", + "DispatchZoneName": "Dispatch Zone Name", + "ErrorAutoIncrementNumberTooLow": "Error: New number must be at least {0} to not conflict with existing records", + "ErrorDBFetchError": "Database error - unable to fetch record: {0}", + "ErrorDBForeignKeyViolation": "This object can not be deleted because it is linked to one or more related objects", + "ErrorDBRecordModifiedExternally": "Database error - record in table {0} was modified by user {1} after you opened it and can not be updated at this time.\\r\\nYou must close this record and re-open it before you can modify or delete it.", + "ErrorDBSchemaMismatch": "Error: This program requires database version {0}, \r\nthe database being opened is version {1}.", + "ErrorGridFilterByOtherColumnNotSupported": "Filtering by comparing to values in other columns \r\n({0})\r\n not supported.", + "ErrorLicenseExpired": "The AyaNova license has expired. Usage will be limited to read only for all users until a valid license key is entered.\r\n\r\nLicenses can be purchased at a fair price quickly and easily, see our website at www.ayanova.com.", + "ErrorLicenseWillExpire": "Warning: this license will expire {0}", + "ErrorLiteDatabase": "Error: AyaNova Lite can only be used with a standalone FireBird database.", + "ErrorDuplicateNameWarning": "Warning: There is an existing item in the database with the same name", + "ErrorDuplicateSerialWarning": "Warning: There is an existing item in the database with the same serial number", + "ErrorFieldLengthExceeded": "{0} can not exceed {1} characters.", + "ErrorFieldLengthExceeded255": "{0} exceeds limit of 255 characters.", + "ErrorFieldLengthExceeded500": "{0} exceeds limit of 500 characters", + "ErrorFieldValueNotBetween": "{0} not valid must be between {1} and {2}.", + "ErrorFieldValueNotValid": "{0} is not valid", + "ErrorNameFetcherNotFound": "Name/bool Fetcher: Field {0} in table {1} with record ID {2} not found!", + "ErrorNotChangeable": "Error: a {0} object can not be changed", + "ErrorNotDeleteable": "Error: a {0} object can not be deleted", + "ErrorRequiredFieldEmpty": "{0} is a required field. Please enter a value for {0}", + "ErrorStartDateAfterEndDate": "Start date must be earlier than stop / end date", + "ErrorSecurityAdministratorOnlyMessage": "You must be logged on as Administrator to access this function", + "ErrorSecurityNotAuthorizedToChange": "Error: User is not authorized to change a {0} or the object or field being changed currently is read only.", + "ErrorSecurityNotAuthorizedToCreate": "Error: Current user not authorized to create a new {0}", + "ErrorSecurityNotAuthorizedToDelete": "Error: Current user is not authorized to delete a {0}", + "ErrorSecurityNotAuthorizedToDeleteDefaultObject": "Error: The default {0} can not be deleted", + "ErrorSecurityNotAuthorizedToRetrieve": "Error: Current user not authorized to open a {0} record", + "ErrorSecurityUserCapacity": "There are not enough available licenses to continue this operation.", + "ErrorTrialRestricted": "Trial mode is restricted to 30 work orders maximum, a work order will need to be deleted before you can add a new one.\r\n\r\nLicenses can be purchased at a fair price quickly and easily, see our website at www.ayanova.com.", + "ErrorUnableToOpenDocumentUrl": "Unable to open document", + "ErrorUnableToOpenEmailUrl": "Unable to open email address", + "ErrorUnableToOpenWebUrl": "Unable to open web address", + "FormFieldDataTypesCurrency": "Money", + "FormFieldDataTypesDateOnly": "Date", + "FormFieldDataTypesDateTime": "Date & Time", + "FormFieldDataTypesNumber": "Number", + "FormFieldDataTypesText": "Text", + "FormFieldDataTypesTimeOnly": "Time", + "FormFieldDataTypesTrueFalse": "True/False", + "GlobalAllowScheduleConflicts": "Allow Schedule Conflicts", + "GlobalAllowScheduleConflictsDescription": "If the user assigning schedules wants to be notified that a scheduled user overlaps, they should set this to FALSE. If it is common to overlap schedules, it is suggested to set to TRUE", + "GlobalCJKIndex": "Use CJK Index", + "GlobalCJKIndexDescription": "Only set to TRUE if entry of Chinese, Japanese or Korean characters into fields and labels", + "GlobalCoordinateStyle": "Coordinate display style", + "GlobalCoordinateStyleDescription": "Determines how geographic co-ordinates are displayed", + "GlobalDefaultLanguageDescription": "Language that all localized labels will be set to.", + "GlobalDefaultLatitude": "Coordinate default latitude hemisphere", + "GlobalDefaultLatitudeDescription": "Default hemisphere for new latitude entry.", + "GlobalDefaultLongitude": "Coordinate default longitude hemisphere", + "GlobalDefaultLongitudeDescription": "Default hemisphere for new longitude entry.", + "GlobalDefaultPartDisplayFormat": "Part display format", + "GlobalDefaultPartDisplayFormatDescription": "Sets the format for how parts are displayed for selection", + "GlobalDefaultScheduleableUserNameDisplayFormat": "User name display format", + "GlobalDefaultScheduleableUserNameDisplayFormatDescription": "Determines the format of how Schedulable Usesrs display within drop down selection boxes.", + "GlobalDefaultServiceTemplateIDDescription": "Template used globally when no other more specific template is in effect", + "GlobalDefaultUnitNameDisplayFormat": "Unit display format", + "GlobalInventoryAdjustmentStartSeed": "Inventory adjustment starting number", + "GlobalInventoryAdjustmentStartSeedDescription": "Inventory Adjustment starting number must be greater than existing utilized numbers. Once have entered a number, can not enter a smaller number", + "GlobalLaborSchedUserDfltTimeSpan": "Scheduled / Labor default minutes", + "GlobalLaborSchedUserDfltTimeSpanDescription": "Scheduled Users/Labor default time span for new records (minutes). 0 = off", + "GlobalMainGridAutoRefresh": "Auto-refresh main grids", + "GlobalMainGridAutoRefreshDescription": "Refresh main grid lists automatically every 5 minutes.", + "GlobalMaxFileSizeMB": "Maximum embedded file size", + "GlobalMaxFileSizeMBDescription": "Largest single file size in megabytes that can be stored embedded in the database", + "GlobalNotifySMTPAccount": "SMTP login", + "GlobalNotifySMTPAccountDescription": "Login account for SMTP mail server", + "GlobalNotifySMTPFrom": "SMTP Reply to / From address", + "GlobalNotifySMTPFromDescription": "From email account (reply to address) to use when sending outgoing notification", + "GlobalNotifySMTPHost": "SMTP server", + "GlobalNotifySMTPHostDescription": "Internet (SMTP) mail server used for sending outgoing notification messages", + "GlobalNotifySMTPPassword": "SMTP password", + "GlobalNotifySMTPPasswordDescription": "Password for SMTP login account", + "GlobalPropertyCategoryDisplayStyle": "Display style", + "GlobalPurchaseOrderStartSeed": "Purchase Orders Start Number", + "GlobalPurchaseOrderStartSeedDescription": "Purchase Orders starting number must be greater than existing utilized numbers. Once have entered a number, can not enter a smaller number.", + "GlobalQuoteNumberStartSeed": "Quotes Start Number", + "GlobalQuoteNumberStartSeedDescription": "Quotes starting number must be greater than existing utilized numbers. Once have entered a number, can not enter a smaller number.", + "GlobalRentalStartSeed": "Rental start number", + "GlobalRentalStartSeedDescription": "Rental starting number must be greater than existing utilized numbers. Once have entered a number, can not enter a smaller number.", + "GlobalSchedUserNonTodayStartTime": "Scheduled default time", + "GlobalSchedUserNonTodayStartTimeDescription": "Scheduled user default time for new records when choosing start date other than today.", + "GlobalSignatureFooter": "Signature footer", + "GlobalSignatureFooterDescription": "Text displayed as footer below signature box", + "GlobalSignatureHeader": "Signature header", + "GlobalSignatureHeaderDescription": "Text displayed as header above signature box", + "GlobalSignatureTitle": "Signature title", + "GlobalSignatureTitleDescription": "Text displayed as title above signature area", + "GlobalSMTPEncryption": "SMTP Encryption", + "GlobalSMTPEncryptionDescription": "Encryption method to use with SMTP server. Valid values are 'TLS', 'SSL' or empty for no encryption.", + "GlobalSMTPRetry": "SMTP Retry deliveries", + "GlobalSMTPRetryDescription": "Don't remove SMTP / SMS notifications if unable to connect to SMTP server; retry them again on next notification processing until delivered.", + "GlobalSpellCheckDescription": "If set to TRUE, all text fields will be compared against internal spell list for the language selected. Setting to TRUE will increase time taken to save a record.", + "GlobalTaxPartPurchaseID": "Default parts purchase tax", + "GlobalTaxPartPurchaseIDDescription": "Sales tax used by default for parts on purchase orders", + "GlobalTaxPartSaleID": "Defaults parts sales tax", + "GlobalTaxPartSaleIDDescription": "Sales tax used by default for parts on workorders", + "GlobalTaxRateSaleID": "Default service sales tax", + "GlobalTaxRateSaleIDDescription": "Sales tax used by default for services on workorders", + "GlobalTravelDfltTimeSpan": "Travel default minutes", + "GlobalTravelDfltTimeSpanDescription": "Travel default time span for new records (minutes). 0 = off", + "GlobalUnitNameDisplayFormatsDescription": "Determines the format of how Units display within drop down selection boxes within service workorders, quotes, and pm's.", + "GlobalUseInventory": "Use Inventory", + "GlobalUseInventoryDescription": "FALSE restricts access to part entry and selection of parts used within service workorders. TRUE allows access to all inventory functions.", + "GlobalUseNotification": "Use Notification", + "GlobalUseNotificationDescription": "If set to TRUE turns on notification system\r\nIf set to FALSE turns off all notification processing.", + "GlobalUseRegions": "Use Regions", + "GlobalUseRegionsDescription": "If set to TRUE, users assigned to one region will not be able to view information about users assigned to another region", + "GlobalWorkorderCloseByAge": "Workorder stale age (minutes)", + "GlobalWorkorderCloseByAgeDescription": "Minutes after a workorder is created that it should be closed. When a work order is created this time span is added to the current date / time to set the close by date automatically. Set to zero if not used.", + "GlobalWorkorderClosedStatus": "Workorder closed status", + "GlobalWorkorderClosedStatusDescription": "If a status is selected here, a work order will be set to this status automatically when closed by a user in AyaNova or AyaNovaWBI", + "GlobalWorkorderNumberStartSeed": "Service Workorders Start Number", + "GlobalWorkorderNumberStartSeedDescription": "Service workorder starting number must be greater than existing utilized numbers. Once have entered a number, can not enter a smaller number.", + "GlobalWorkorderSummaryTemplate": "Workorder Item Summary Template", + "GlobalWorkorderSummaryTemplateDescription": "This determines what information from a service workorder item will display on the Schedule screen.", + "GridFilterName": "Filter name", + "HeadOfficeAccountNumber": "Account Number", + "HeadOfficeContact": "Contact", + "HeadOfficeContactNotes": "Other contacts", + "HeadOfficeCustom0": "Custom field 0", + "HeadOfficeCustom1": "Custom field 1", + "HeadOfficeCustom2": "Custom field 2", + "HeadOfficeCustom3": "Custom field 3", + "HeadOfficeCustom4": "Custom field 4", + "HeadOfficeCustom5": "Custom field 5", + "HeadOfficeCustom6": "Custom field 6", + "HeadOfficeCustom7": "Custom field 7", + "HeadOfficeCustom8": "Custom field 8", + "HeadOfficeCustom9": "Custom field 9", + "HeadOfficeEmail": "Email", + "HeadOfficeList": "Head Offices", + "HeadOfficeName": "Head Office Name", + "HeadOfficeNotes": "Notes", + "HeadOfficePhone1": "Business", + "HeadOfficePhone2": "Fax", + "HeadOfficePhone3": "Home", + "HeadOfficePhone4": "Mobile", + "HeadOfficePhone5": "Pager", + "KeyNotFound": "No key was found on the clipboard", + "KeyNotValid": "Key could not be validated", + "KeySaved": "Key has been saved, restart AyaNova at all computers now", + "LoanItemCurrentWorkorderItemLoan": "Current workorder item loan ID", + "LoanItemCustom0": "Custom field 0", + "LoanItemCustom1": "Custom field 1", + "LoanItemCustom2": "Custom field 2", + "LoanItemCustom3": "Custom field 3", + "LoanItemCustom4": "Custom field 4", + "LoanItemCustom5": "Custom field 5", + "LoanItemCustom6": "Custom field 6", + "LoanItemCustom7": "Custom field 7", + "LoanItemCustom8": "Custom field 8", + "LoanItemCustom9": "Custom field 9", + "LoanItemList": "Loan Items", + "LoanItemName": "Name", + "LoanItemNotes": "Notes", + "LoanItemRateDay": "Day rate", + "LoanItemRateHalfDay": "Half day rate", + "LoanItemRateHour": "Hour rate", + "LoanItemRateMonth": "Month rate", + "LoanItemRateNone": "-", + "LoanItemRateWeek": "Week rate", + "LoanItemRateYear": "Year rate", + "LoanItemSerial": "Serial number", + "LocaleCustomizeText": "Customize text", + "LocaleExport": "Export locale to file", + "LocaleImport": "Import locale from file", + "LocaleList": "Localized text collection", + "LocaleLocaleFile": "AyaNova transportable Locale file (*.xml)", + "LocaleUIDestLocale": "New locale name", + "LocaleUISourceLocale": "Source locale", + "LocaleWarnLocaleLocked": "Your user account is using the \"English\" locale text.\r\nThis locale is read only and can not be edited.\r\nPlease change your locale in your user settings to any other value than \"English\" to proceed.", + "LocalizedTextDisplayText": "Standard display text", + "LocalizedTextDisplayTextCustom": "Custom display text", + "LocalizedTextKey": "Key", + "LocalizedTextLocale": "Language", + "MemoForward": "Forward", + "MemoReply": "Reply", + "MemoEventCreated": "Memo - incoming", + "MemoFromID": "From", + "MemoList": "Memos", + "MemoMessage": "Message", + "MemoRe": "RE:", + "MemoReplied": "Replied", + "MemoSent": "Sent", + "MemoSentRelative": "Sent (relative)", + "MemoSubject": "Subject", + "MemoToID": "To", + "MemoViewed": "Viewed", + "NotifyNotificationMessage": "Message", + "NotifySourceOfEvent": "Source", + "NotifyDeliveryLogDelivered": "Delivery sucessful", + "NotifyDeliveryLogDeliveryDate": "Delivered at", + "NotifyDeliveryLogErrorMessage": "Error message", + "NotifyDeliveryLogList": "Notification deliveries (last 7 days)", + "NotifyDeliveryLogToUser": "Delivered to", + "NotifyDeliveryMessageFormatsBrief": "Brief compact format", + "NotifyDeliveryMessageFormatsFull": "Full format", + "NotifyDeliveryMethodsMemo": "AyaNova memo", + "NotifyDeliveryMethodsPopUp": "Pop up message box", + "NotifyDeliveryMethodsSMS": "SMS enabled device", + "NotifyDeliveryMethodsSMTP": "Internet mail account", + "NotifyDeliverySettingAddress": "Address", + "NotifyDeliverySettingAllDay": "All day", + "NotifyDeliverySettingAnyTime": "Deliver notification at any time", + "NotifyDeliverySettingDeliver": "Deliver", + "NotifyDeliverySettingDeliveryMethod": "Physical delivery method", + "NotifyDeliverySettingEndTime": "End time", + "NotifyDeliverySettingEventWindows": "Deliver notification on these times only:", + "NotifyDeliverySettingList": "Notification delivery methods", + "NotifyDeliverySettingMaxCharacters": "Maximum characters", + "NotifyDeliverySettingMessageFormat": "Message format", + "NotifyDeliverySettingName": "Name", + "NotifyDeliverySettingStartTime": "Start time", + "NotifySubscriptionCreated": "Subscribed", + "NotifySubscriptionEventDescription": "Event", + "NotifySubscriptionList": "Notification subscriptions", + "NotifySubscriptionPendingSpan": "Notify before event", + "NotifySubscriptionWarningNoDeliveryMethod": "At least one notification delivery method is required to subscribe to notifications. Set one up now?", + "NotifySubscriptionDeliveryUIAddNew": "Add delivery method", + "Address": "Address", + "AssignedDoc": "Document", + "AyaFile": "Embedded file", + "Client": "Client", + "ClientGroup": "Client Group", + "ClientNote": "Client note", + "ClientNoteType": "Client note type", + "ClientRequestPart": "Requested Part", + "ClientRequestTech": "Requested Scheduleable User", + "ClientRequestWorkorder": "Requested workorder", + "ClientRequestWorkorderItem": "Requested workorder item", + "ClientServiceRequest": "Client service request", + "ClientServiceRequestItem": "Client Service Request Item", + "Contact": "Contact", + "ContactPhone": "Contact phone", + "ContactTitle": "Contact Title", + "Contract": "Contract", + "ContractPart": "Contract Part", + "ContractRate": "Contract rate", + "DispatchZone": "Dispatch Zone", + "Global": "Global", + "GlobalWikiPage": "Global Wiki page", + "GridFilter": "GridFilter", + "HeadOffice": "Head Office", + "LoanItem": "Loan item", + "Locale": "Locale", + "LocalizedText": "Localized text", + "Maintenance": "AyaNova internal maintenance", + "Memo": "Memo", + "NameFetcher": "NameFetcher object", + "Notification": "Notification", + "NotifySubscription": "Notification subscription", + "NotifySubscriptionDelivery": "Notification delivery method", + "Part": "Part", + "PartAssembly": "Part Assembly", + "PartByWarehouseInventory": "Part by warehouse inventory", + "PartCategory": "Part category", + "PartInventoryAdjustment": "Part inventory adjustment", + "PartInventoryAdjustmentItem": "Part inventory adjustment item", + "PartSerial": "Serialized part", + "PartWarehouse": "Part Warehouse", + "PreventiveMaintenance": "Preventive Maintenance", + "Priority": "Priority", + "Project": "Project", + "PurchaseOrder": "Purchase Order", + "PurchaseOrderItem": "Purchase Order Item", + "PurchaseOrderReceipt": "Purchase Order Receipt", + "PurchaseOrderReceiptItem": "Purchase order receipt item", + "Rate": "Rate", + "RateUnitChargeDescription": "Rate unit charge description", + "Region": "Region", + "Rental": "Rental", + "RentalUnit": "Rental unit", + "Report": "Report", + "ScheduleableUserGroup": "Scheduleable user group", + "ScheduleableUserGroupUser": "Scheduleable User Group User", + "ScheduleForm": "Schedule form", + "ScheduleMarker": "Schedule Marker", + "SecurityGroup": "Security group", + "ServiceBank": "Service bank", + "Task": "Task", + "TaskGroup": "Task group", + "TaskGroupTask": "TaskGroup task", + "TaxCode": "Tax code", + "Unit": "Unit", + "UnitMeterReading": "Unit Meter Reading", + "UnitModel": "Unit model", + "UnitModelCategory": "Unit Model Category", + "UnitOfMeasure": "Unit of measure", + "UnitServiceType": "Unit service type", + "User": "User", + "UserCertification": "User certification", + "UserCertificationAssigned": "UserCertificationAssigned", + "UserRight": "UserRight object", + "UserSkill": "User skill", + "UserSkillAssigned": "User Skill Assigned", + "Vendor": "Vendor", + "WikiPage": "Wiki page", + "Workorder": "Workorder", + "WorkorderClose": "Close work order", + "WorkorderCategory": "Workorder Category", + "WorkorderItem": "Workorder Item", + "WorkorderItemLabor": "Workorder item labor", + "WorkorderItemLoan": "Workorder item loan", + "WorkorderItemMiscExpense": "Workorder item misc expense", + "WorkorderItemPart": "Workorder item part", + "WorkorderItemPartRequest": "Workorder item part request", + "WorkorderItemScheduledUser": "Workorder item Scheduled User", + "WorkorderItemTask": "Workorder item task", + "WorkorderItemTravel": "Workorder item travel", + "WorkorderItemType": "Workorder Item Type", + "WorkorderItemUnit": "Workorder item unit", + "WorkorderPreventiveMaintenance": "Preventive maintenance", + "WorkorderPreventiveMaintenanceTemplate": "Preventive maintenance template", + "WorkorderQuote": "Quote", + "WorkorderQuoteTemplate": "Quote template", + "WorkorderService": "Workorder", + "WorkorderServiceTemplate": "Service template", + "WorkorderStatus": "Workorder status", + "ObjectCustomFieldCustomGrid": "Custom Fields", + "ObjectCustomFieldDisplayName": "Display as", + "ObjectCustomFieldFieldName": "Field Name", + "ObjectCustomFieldFieldType": "Field data type", + "ObjectCustomFieldObjectName": "Object name", + "ObjectCustomFieldVisible": "Visible", + "OutsideServiceList": "Outside Service List", + "PartMustTrackSerial": "Track serial numbers can not be set to false as this part has a history with serial numbers already recorded", + "PartTrackSerialHasInventory": "Track serial numbers can not be turned on as this part still has items in inventory", + "PartAlert": "Alert text", + "PartAlternativeWholesalerID": "Alternative Wholesaler", + "PartAlternativeWholesalerNumber": "Alternative Wholesaler Number", + "PartCustom0": "Custom field 0", + "PartCustom1": "Custom field 1", + "PartCustom2": "Custom field 2", + "PartCustom3": "Custom field 3", + "PartCustom4": "Custom field 4", + "PartCustom5": "Custom field 5", + "PartCustom6": "Custom field 6", + "PartCustom7": "Custom field 7", + "PartCustom8": "Custom field 8", + "PartCustom9": "Custom field 9", + "PartList": "Parts", + "PartManufacturerID": "Manufacturer", + "PartManufacturerNumber": "Manufacturer Number", + "PartName": "Part Name", + "PartNotes": "Notes", + "PartPartNumber": "Part Number", + "PartRetail": "Retail", + "PartTrackSerialNumber": "Track Serial Number", + "PartUPC": "UPC", + "PartWholesalerID": "Wholesaler", + "PartWholesalerNumber": "Wholesaler Number", + "PartAssemblyDescription": "Description", + "PartAssemblyList": "Part Assemblies", + "PartAssemblyName": "Part Assembly Name", + "PartByWarehouseInventoryList": "Part Inventory", + "PartByWarehouseInventoryMinStockLevel": "Restock level", + "PartByWarehouseInventoryQtyOnOrderCommitted": "Quantity on order committed", + "PartByWarehouseInventoryQuantityOnHand": "On Hand", + "PartByWarehouseInventoryQuantityOnOrder": "On Order", + "PartByWarehouseInventoryReorderQuantity": "Reorder quantity", + "PartCategoryList": "Part Categories", + "PartCategoryName": "Part Category Name", + "PartDisplayFormatsAssemblyNumberName": "Assembly - number - name", + "PartDisplayFormatsCategoryNumberName": "Category - number - name", + "PartDisplayFormatsManufacturerName": "Manufacturer - name", + "PartDisplayFormatsManufacturerNumber": "Manufacturer - number", + "PartDisplayFormatsName": "Name only", + "PartDisplayFormatsNameCategoryNumberManufacturer": "Name - category - number - manufacturer", + "PartDisplayFormatsNameNumber": "Name - number", + "PartDisplayFormatsNameNumberManufacturer": "Name - number - manufacturer", + "PartDisplayFormatsNameUPC": "Name - UPC", + "PartDisplayFormatsNumber": "Number only", + "PartDisplayFormatsNumberName": "Number - name", + "PartDisplayFormatsNumberNameManufacturer": "Number - name - manufacturer", + "PartDisplayFormatsUPC": "UPC only", + "PartInventoryAdjustmentAdjustmentNumber": "Number", + "PartInventoryAdjustmentDateAdjusted": "Date Adjusted", + "PartInventoryAdjustmentPartInventoryAdjustmentID": "Adjustment ID", + "PartInventoryAdjustmentReasonForAdjustment": "Reason", + "PartInventoryAdjustmentItemNegativeQuantityInvalid": "There are not enough or no parts of this kind in this warehouse to remove from inventory", + "PartInventoryAdjustmentItemPartNotUnique": "The same part / warehouse combination can only be used once in a single adjustment", + "PartInventoryAdjustmentItemZeroQuantityInvalid": "A quantity is required", + "PartInventoryAdjustmentItemQuantityAdjustment": "Quantity Adjustment", + "PartRestockRequiredByVendorList": "Part Restock Required By Vendor", + "PartSerialAdjustmentID": "Adjustment", + "PartSerialAvailable": "Available", + "PartSerialDateConsumed": "Consumed", + "PartSerialDateReceived": "Received", + "PartSerialSerialNumberNotUnique": "Serial number already entered for this part", + "PartSerialWarehouseID": "Part Warehouse", + "PartWarehouseDescription": "Description", + "PartWarehouseList": "Parts Warehouses", + "PartWarehouseName": "Part Warehouse Name", + "PriorityColor": "Color", + "PriorityList": "Priorities", + "PriorityName": "Priority Name", + "ProjectAccountNumber": "Account Number", + "ProjectCustom0": "Custom field 0", + "ProjectCustom1": "Custom field 1", + "ProjectCustom2": "Custom field 2", + "ProjectCustom3": "Custom field 3", + "ProjectCustom4": "Custom field 4", + "ProjectCustom5": "Custom field 5", + "ProjectCustom6": "Custom field 6", + "ProjectCustom7": "Custom field 7", + "ProjectCustom8": "Custom field 8", + "ProjectCustom9": "Custom field 9", + "ProjectDateCompleted": "Date Completed", + "ProjectDateStarted": "Date Started", + "ProjectList": "Projects", + "ProjectName": "Project Name", + "ProjectNotes": "Notes", + "ProjectProjectOverseerID": "Project Overseer", + "PurchaseOrderActualReceiveDate": "Due Date", + "PurchaseOrderCustom0": "Custom field 0", + "PurchaseOrderCustom1": "Custom field 1", + "PurchaseOrderCustom2": "Custom field 2", + "PurchaseOrderCustom3": "Custom field 3", + "PurchaseOrderCustom4": "Custom field 4", + "PurchaseOrderCustom5": "Custom field 5", + "PurchaseOrderCustom6": "Custom field 6", + "PurchaseOrderCustom7": "Custom field 7", + "PurchaseOrderCustom8": "Custom field 8", + "PurchaseOrderCustom9": "Custom field 9", + "PurchaseOrderDropShipToClientID": "Drop Ship to Client", + "PurchaseOrderLocked": "Purchase order is locked due to it's status", + "PurchaseOrderExpectedReceiveDate": "Expected", + "PurchaseOrderNotes": "Notes", + "PurchaseOrderOrderedDate": "Ordered Date", + "PurchaseOrderPONumber": "PO Number", + "PurchaseOrderStatusClosedFullReceived": "Closed - fully received", + "PurchaseOrderStatusClosedNoneReceived": "Closed - none received", + "PurchaseOrderStatusClosedPartialReceived": "Closed - partially received", + "PurchaseOrderStatusOpenNotYetOrdered": "Open - not yet ordered", + "PurchaseOrderStatusOpenOrdered": "Open - on order", + "PurchaseOrderStatusOpenPartialReceived": "Open - partially received", + "PurchaseOrderReferenceNumber": "Reference Number", + "PurchaseOrderShowPartsAllVendors": "Select from any vendor's part", + "PurchaseOrderStatus": "Purchase Order Status", + "PurchaseOrderUICopyToPurchaseOrder": "Copy to P.O.", + "PurchaseOrderUINoPartsForVendorWarning": "The selected vendor has no parts defined in AyaNova. You will not be able to enter any purchase order items for this vendor.", + "PurchaseOrderUIOrderedWarning": "Are you sure you want to set this P.O. to Ordered status?", + "PurchaseOrderUIRestockList": "Restock list", + "PurchaseOrderVendorMemo": "Vendor Memo", + "PurchaseOrderItemClosed": "Closed", + "PurchaseOrderItemLineTotal": "Line Total", + "PurchaseOrderItemNetTotal": "Net Total", + "PurchaseOrderItemPartName": "Part Name", + "PurchaseOrderItemPartNumber": "Part Number", + "PurchaseOrderItemPartRequestedByID": "Requested by", + "PurchaseOrderItemPurchaseOrderCost": "P.O. Cost", + "PurchaseOrderItemQuantityOrdered": "Quantity Ordered", + "PurchaseOrderItemQuantityReceived": "Quantity Received", + "PurchaseOrderItemUIOrderedFrom": "Ordered from", + "PurchaseOrderItemUISaveWarning": "Are you sure you want to save?\r\nOnce this record is saved it will be locked and can no longer be edited.", + "PurchaseOrderItemWorkorderNumber": "Workorder #", + "PurchaseOrderReceiptItems": "Purchase Order Receipt Items", + "PurchaseOrderReceiptPartRequestNotFound": "Part request not found", + "PurchaseOrderReceiptReceivedDate": "Received Date", + "PurchaseOrderReceiptText1": "Text1", + "PurchaseOrderReceiptText2": "Text2", + "PurchaseOrderReceiptItemPONumber": "Purchase Order", + "PurchaseOrderReceiptItemPurchaseOrderItemID": "Purchase Order Item", + "PurchaseOrderReceiptItemPurchaseOrderReceiptID": "Purchase Order Receipt", + "PurchaseOrderReceiptItemQuantityReceived": "Quantity Received", + "PurchaseOrderReceiptItemQuantityReceivedErrorInvalid": "Entry must be equal or less than outstanding amount from purchase order and non-negative", + "PurchaseOrderReceiptItemReceiptCost": "Actual Cost", + "PurchaseOrderReceiptItemReferenceNumber": "Reference", + "PurchaseOrderReceiptItemWarehouseID": "Part Warehouse", + "PurchaseOrderReceiptItemWorkorderNumber": "Workorder #", + "RateAccountNumber": "Account Number", + "RateCharge": "Retail Charge", + "RateClientGroupID": "Client Group", + "RateContractRate": "Contract Rate", + "RateDescription": "Description", + "RateList": "Rates", + "RateName": "Rate Name", + "RateRateType": "Rate Type", + "RateRateTypeRental": "Rental", + "RateRateTypeService": "Service", + "RateRateTypeTravel": "Travel", + "RateRateUnitChargeDescriptionID": "Unit Charge Description", + "RateUnitChargeDescriptionList": "Rate Units", + "RateUnitChargeDescriptionName": "Rate unit charge description name", + "RateUnitChargeDescriptionNamePlural": "Plural name", + "RegionAttachQuote": "Attach quote report", + "RegionAttachWorkorder": "Attach workorder report", + "RegionClientNotifyMessage": "Message to send to client", + "RegionCSRAccepted": "CSR accepted", + "RegionCSRRejected": "CSR rejected", + "RegionDefaultPurchaseOrderTemplate": "Default purchase order template", + "RegionDefaultQuoteTemplate": "Default quote template", + "RegionDefaultWorkorderTemplate": "Default work order template", + "RegionFollowUpDays": "Days after work order closed", + "RegionList": "Regions", + "RegionName": "Region Name", + "RegionNewWO": "New WO", + "RegionQuoteStatusChanged": "Quote status changed", + "RegionReplyToEmailAddress": "Reply to email address", + "RegionWBIUrl": "AyaNova WBI url address", + "RegionWOClosedEmailed": "WO Closed", + "RegionWOFollowUp": "WO follow up", + "RegionWorkorderClosedStatus": "Work order closed status", + "RegionWOStatusChanged": "WO status changed", + "ReportActive": "Active", + "ReportDesignReport": "Design", + "ReportImportDuplicate": "Selected report can not be imported: There is already a report with the same internal ID value in your database", + "ReportExport": "Export to...", + "ReportExportHTML": "HTML file (*.html)", + "ReportExportPDF": "Acrobat file (*.pdf)", + "ReportExportRTF": "Rich text file (*.rtf)", + "ReportExportTIFF": "TIFF file (*.tif)", + "ReportExportTXT": "Text file (*.txt)", + "ReportExportXLS": "Excel file (*.xls)", + "ReportExportLayout": "Export Template", + "ReportExportLayoutFile": "AyaNova transportable report file (*.ayr)", + "ReportImportLayout": "Import Template", + "ReportList": "Report Templates", + "ReportMaster": "Master report", + "ReportMasterWarning": "Master report - read only", + "ReportName": "Name", + "ReportNewDetailedReport": "New detailed report template", + "ReportNewSummaryReport": "New summary report template", + "ReportReportKey": "Key", + "ReportReportSize": "Size (bytes)", + "ReportSaveAsDialogTitle": "Save report template as...", + "ReportSecurityGroupID": "Restrict to security group", + "ReportEditorControls": "Toolbox", + "ReportEditorExplorer": "Explorer", + "ReportEditorFields": "Fields", + "ReportEditorProperties": "Properties", + "ScheduleableUserGroupDescription": "Description", + "ScheduleableUserGroupList": "Scheduleable user groups", + "ScheduleableUserGroupName": "Scheduleable User Group Name", + "ScheduleableUserGroupScheduleableUsers": "Scheduleable Users", + "ScheduleableUserGroupUserScheduleableUserGroupID": "Scheduleable User Group ID", + "ScheduleableUserGroupUserScheduleableUserID": "Scheduleable User", + "ScheduleableUserNameDisplayFormatsEmployeeNumberFirstLast": "EmployeeNumber - First Last", + "ScheduleableUserNameDisplayFormatsEmployeeNumberInitials": "Employee number - initials", + "ScheduleableUserNameDisplayFormatsFirstLast": "First Last", + "ScheduleableUserNameDisplayFormatsFirstLastRegion": "First Last - Region", + "ScheduleableUserNameDisplayFormatsInitials": "Initials", + "ScheduleableUserNameDisplayFormatsLastFirst": "Last, First", + "ScheduleableUserNameDisplayFormatsLastFirstRegion": "Last, First - Region", + "ScheduleableUserNameDisplayFormatsRegionFirstLast": "Region - First Last", + "ScheduleableUserNameDisplayFormatsRegionLastFirst": "Region - Last, First", + "ScheduledList": "Scheduled List", + "ScheduleMarkerARGB": "ARGB", + "ScheduleMarkerColor": "Color", + "ScheduleMarkerCompleted": "Completed", + "ScheduleMarkerEventCreated": "Schedule marker - Just created", + "ScheduleMarkerEventPendingAlert": "Schedule marker - Event imminent", + "ScheduleMarkerFollowUp": "Follow up", + "ScheduleMarkerList": "Schedule markers", + "ScheduleMarkerName": "Name", + "ScheduleMarkerNotes": "Notes", + "ScheduleMarkerRecurrence": "Recurrence", + "ScheduleMarkerScheduleMarkerSourceType": "For", + "ScheduleMarkerSourceID": "Source", + "ScheduleMarkerStartDate": "Start", + "ScheduleMarkerStopDate": "Stop", + "SearchResultDescription": "Description", + "SearchResultExtract": "Extract", + "SearchResultRank": "Rank", + "SearchResultSource": "Source", + "SecurityGroupList": "Security Groups", + "SecurityGroupName": "Security Group Name", + "SecurityLevelTypesNoAccess": "Forbidden", + "SecurityLevelTypesReadOnly": "Read only", + "SecurityLevelTypesReadWrite": "Read / write", + "SecurityLevelTypesReadWriteDelete": "Read / write / delete", + "ServiceBankAppliesToRootObjectID": "Applies to root object", + "ServiceBankAppliesToRootObjectType": "Applies to root object type", + "ServiceBankCreated": "Entered", + "ServiceBankCreator": "User", + "ServiceBankCurrency": "Currency", + "ServiceBankCurrencyBalance": "Currency balance", + "ServiceBankDescription": "Description", + "ServiceBankEffectiveDate": "Effective date", + "ServiceBankEventCurrencyBalanceZero": "Service bank - currency depleted", + "ServiceBankEventHoursBalanceZero": "Sevice bank - hours depleted", + "ServiceBankEventIncidentsBalanceZero": "Service bank - incidents depleted", + "ServiceBankHours": "Hours", + "ServiceBankHoursBalance": "Hours balance", + "ServiceBankID": "ID", + "ServiceBankIncidents": "Incidents", + "ServiceBankIncidentsBalance": "Incidents balance", + "ServiceBankList": "Service Bank List", + "ServiceBankSourceRootObjectID": "Source ID", + "ServiceBankSourceRootObjectType": "Source", + "StopWords1": "do said his both could with take like still much they been will her your how through were other or there never is here where then my must as when him them most re which if he had at", + "StopWords2": "want many so to the be else did than that of only being got about you their our on this too has any might can before are way now since we should another also into me it by after and in", + "StopWords3": "would some what such make come while its use those see out who ll but get have same up well because between for all each does came just from was an these himself very under over more", + "StopWords4": "?", + "StopWords5": "?", + "StopWords6": "?", + "StopWords7": "?", + "TaskList": "Tasks", + "TaskName": "Task Name", + "TaskGroupDescription": "Description", + "TaskGroupList": "Task groups", + "TaskGroupName": "Task group name", + "TaskGroupTaskTaskGroupID": "Task Group", + "TaxCodeDefault": "Error: while this tax code is a default in global settings, it can not be deleted or set inactive", + "TaxCodeList": "Tax Codes", + "TaxCodeName": "Tax Code Name", + "TaxCodeNotes": "Notes", + "TaxCodeTaxA": "Tax \"A\"", + "TaxCodeTaxAExempt": "Tax \"A\" Exempt", + "TaxCodeTaxAValue": "Tax A Value", + "TaxCodeTaxB": "Tax \"B\"", + "TaxCodeTaxBExempt": "Tax \"B\" Exempt", + "TaxCodeTaxBValue": "Tax B Value", + "TaxCodeTaxOnTax": "Tax on Tax", + "Add": "Add", + "Cancel": "Cancel", + "Close": "Exit", + "Closed": "Change Closed Status", + "CurrentDateAndTime": "Current date and time", + "CustomFieldDesign": "Custom Field Design", + "Delete": "Delete", + "Duplicate": "Duplicate", + "Edit": "Edit", + "ExternalTools": "External tools", + "LocalizedTextDesign": "Localized Text Design", + "OK": "OK", + "Open": "Open", + "Ordered": "Ordered", + "Paste": "Paste", + "RecordHistory": "Record History", + "Save": "Save", + "SaveClose": "Save and Exit", + "SaveNew": "Save and New", + "Search": "Search", + "ServiceHistory": "Service History", + "Administration": "Administration", + "AdministrationGlobalSettings": "Global settings", + "Home": "Home", + "Inventory": "Inventory", + "InventoryPartInventoryAdjustments": "Adjustments", + "InventoryPartInventoryAdjustmentsDetailed": "Items", + "InventoryPurchaseOrderReceipts": "Purchase Order Receipts", + "InventoryPurchaseOrderReceiptsReceive": "Receive Inventory", + "InventoryPurchaseOrderReceiptsDetailed": "Items", + "InventoryPurchaseOrders": "Purchase Orders", + "InventoryPurchaseOrdersDetailed": "Items", + "Logout": "Log out", + "Quotes": "Quotes", + "Schedule": "Schedule", + "Service": "Service", + "ServicePreventiveMaintenance": "PM", + "ServiceQuotes": "Quotes", + "UnitModels": "Unit Models", + "UserMail": "Mail", + "UserPreferences": "User Preferences", + "UserPunchClock": "User PunchClock", + "VendorsSubContractors": "SubContractors", + "VendorsWholesalers": "Wholesalers", + "GridFilterDialogAddConditionButtonText": "&Add a condition", + "GridFilterDialogAndRadioText": "And conditions", + "GridFilterDialogCancelButtonText": "&Cancel", + "GridFilterDialogDeleteButtonText": "Delete condition", + "GridFilterDialogOkButtonNoFiltersText": "N&o filters", + "GridFilterDialogOkButtonText": "&OK", + "GridFilterDialogOrRadioText": "Or conditions", + "GridRowFilterDialogBlanksItem": "(Blanks)", + "GridRowFilterDialogDBNullItem": "(DBNull)", + "GridRowFilterDialogEmptyTextItem": "(Empty Text)", + "GridRowFilterDialogOperandHeaderCaption": "Operand", + "GridRowFilterDialogOperatorHeaderCaption": "Operator", + "GridRowFilterDialogTitlePrefix": "Enter filter criteria for", + "GridRowFilterDropDownAllItem": "(All)", + "GridRowFilterDropDownBlanksItem": "(Blanks)", + "GridRowFilterDropDownCustomItem": "(Custom)", + "GridRowFilterDropDownEquals": "Equal to", + "GridRowFilterDropDownGreaterThan": "Greater than", + "GridRowFilterDropDownGreaterThanOrEqualTo": "Greater than or equal to", + "GridRowFilterDropDownLessThan": "Less than", + "GridRowFilterDropDownLessThanOrEqualTo": "Less than or equal to", + "GridRowFilterDropDownLike": "Like", + "GridRowFilterDropDownMatch": "Matches regular expression", + "GridRowFilterDropDownNonBlanksItem": "(NonBlanks)", + "GridRowFilterDropDownNotEquals": "Does not equal to", + "GridRowFilterRegexError": "Error parsing regular expresion {0}. Please enter a valid regular expression.", + "GridRowFilterRegexErrorCaption": "Invalid regular expression", + "HelpAboutAyaNova": "About AyaNova", + "HelpCheckForUpdates": "Check For Updates", + "HelpContents": "&Contents...", + "HelpLicense": "License", + "HelpPurchaseLicenses": "Purchase Licenses", + "HelpTechSupport": "Technical support", + "AllDay": "All day", + "AnyUser": "All users", + "ComboMoreRecordsPrompt": "< More... >", + "CopyOfText": "Copy of", + "DateRange14DayWindow": "Window - 14 days", + "DateRangeFuture": "Future", + "DateRangeInTheLastSixMonths": "In the last 6 months", + "DateRangeInTheLastThreeMonths": "In the last 3 months", + "DateRangeInTheLastYear": "In the last year", + "DateRangeLastMonth": "Month - Previous", + "DateRangeLastWeek": "Week - Previous", + "DateRangeLastYear": "Year - Last", + "DateRangeNextMonth": "Month - Next", + "DateRangeNextWeek": "Week - Next", + "DateRangePast": "Past", + "DateRangeThisMonth": "Month - Current", + "DateRangeThisWeek": "Week - Current", + "DateRangeThisYear": "Year - Current", + "DateRangeToday": "Today", + "DateRangeTomorrow": "Tomorrow", + "DateRangeYesterday": "Yesterday", + "DayAny": "Any day of the week", + "DayFriday": "Friday", + "DayMonday": "Monday", + "DaySaturday": "Saturday", + "DaySunday": "Sunday", + "DayThursday": "Thursday", + "DayTuesday": "Tuesday", + "DayWednesday": "Wednesday", + "DayOfWeek": "Day of week", + "DeletePrompt": "Are you sure you want to delete this record permanently?", + "DeleteWorkorderPrompt": "Are you sure you want to delete this Workorder permanently?", + "East": "East", + "FilterAvailableTo": "Available to", + "Filtered": "Filtered", + "FilterNone": "No filter", + "FilterUnsaved": "Unsaved filter", + "Find": "Find", + "FindAndReplace": "Find and replace", + "FirstRows100": "First 100 rows", + "FirstRows1000": "First 1000 rows", + "FirstRows500": "First 500 rows", + "FirstRowsAll": "All rows", + "LastServiceDate": "Last closed service date", + "LastWorkorder": "Last closed service workorder", + "LineTotal": "Line Total", + "NetValue": "Net", + "North": "North", + "Object": "Object", + "Replace": "Replace", + "SavePrompt": "Do you want to save changes?", + "SelectItem": "Select", + "SelectPurchaseOrdersToReceive": "Select Purchase Orders to Receive...", + "SelectVendor": "Select Vendor...", + "SetLoginPassword": "Set Login && Password", + "ShowAll": "Show all...", + "South": "South", + "SubTotal": "SubTotal", + "TimeSpanDays": "days", + "TimeSpanFutureRelative": "from now", + "TimeSpanHours": "hours", + "TimeSpanMinutes": "minutes", + "TimeSpanMonths": "months", + "TimeSpanPastRelative": "ago", + "TimeSpanSeconds": "seconds", + "TimeSpanWeeks": "weeks", + "TimeSpanWithinTheHour": "Within the hour", + "TimeSpanYears": "years", + "Total": "Total", + "UnsaveableDueToBrokenRulesPrompt": "This record can't be saved because it has one or more broken rules. \r\n\r\nProceed without saving?", + "West": "West", + "MenuGo": "Navigation", + "MenuHelp": "Help", + "MenuMRU": "Recent...", + "MenuSubGrids": "SubGrids", + "RecordHistoryCreated": "Date this record was created", + "RecordHistoryCreator": "Creator of this record", + "RecordHistoryModified": "Date this record was last modified", + "RecordHistoryModifier": "Last modifier of this record", + "AddRemoveButtons": "Add and Remove Buttons....", + "ClientsToolBar": "Clients ToolBar", + "Customize": "Customize....", + "CustomizeDialog_AlwaysShowFullMenus": "Always show full menus", + "CustomizeDialog_CloseNoAmp": "Close", + "CustomizeDialog_FloatingToolbarFadeDelay": "Floating Toolbar Fade Delay", + "CustomizeDialog_KeyboardBeginAmp": "Keyboard....", + "CustomizeDialog_LargeIconsOnMenus": "Large Icons On Menus", + "CustomizeDialog_LargeIconsOnToolbars": "Large Icons On Toolbars", + "CustomizeDialog_Milliseconds": "Milliseconds", + "CustomizeDialog_New": "New....", + "CustomizeDialog_Other": "Other", + "CustomizeDialog_PersonalizedMenusAndToolbars": "Personalized Menus and Toolbars", + "CustomizeDialog_Rename": "Rename....", + "CustomizeDialog_ResetAmp": "Reset....", + "CustomizeDialog_ResetMyUsageData": "Reset my usage data", + "CustomizeDialog_SelectedCommand": "Selected command:", + "CustomizeDialog_ShowFullMenusAfterAShortDelay": "Show full menus after a short delay", + "CustomizeDialog_ShowScreenTipsOnToolbars": "Show ScreenTips On Toolbars", + "CustomizeDialog_ShowShortcutKeysInScreenTips": "Show Shortcut Keys In ScreenTips", + "CustomizeDlgKbdCat": "Categories:", + "CustomizeDlgKbdCmd": "Commands", + "MainMenuBar": "Main MenuBar", + "MainToolBar": "Main ToolBar", + "New": "New....", + "PageSetup": "Page setup", + "Print": "Print", + "PrintPreview": "Print preview", + "Refresh": "Refresh...", + "ResetToolbar": "Reset Toolbar", + "ScheduleActiveWorkorderItem": "Active workorder item:", + "ScheduleAddToActiveWorkorderItem": "Add selection to active workorder item", + "ScheduleDayView": "Single Day View", + "ScheduleEditScheduleableUserGroup": "Edit Scheduleable User Groups", + "ScheduleEditScheduleMarker": "Edit selected Schedule Marker", + "ScheduleEditWorkorder": "Edit selected Workorder", + "ScheduleMergeUsers": "Display Merged / Separate", + "ScheduleMonthView": "Month View", + "ScheduleNewScheduleMarker": "New Schedule Marker", + "ScheduleNewWorkorder": "New Service Workorder", + "SchedulePrintWorkorders": "Print selected work orders", + "ScheduleSelectScheduleableUserGroup": "Select Scheduleable Users", + "ScheduleShowClosed": "Display Open / Closed (and open) work orders", + "ScheduleTimeLineView": "Display single day view as time line / regular", + "ScheduleToday": "Today", + "ScheduleWeekView": "7 Day Week View", + "ScheduleWorkWeekView": "5 Day Work Week View", + "ScheduleToolBar": "Schedule ToolBar", + "SecurityGroupFormSetAll": "Set all security levels to selected level", + "WorkorderFormSetAllPartsUsedInService": "Set all parts to used", + "UIGridLayoutDescription": "Grid layout description", + "UIGridLayoutGridKey": "Grid key", + "UIGridLayoutLayoutContent": "Grid layout data", + "UIGridLayoutLayoutSize": "Grid layout data size", + "UIGridLayoutObjectName": "UI Grid Layout object", + "UnitBoughtHere": "Purchased Here", + "UnitCustom0": "Custom field 0", + "UnitCustom1": "Custom field 1", + "UnitCustom2": "Custom field 2", + "UnitCustom3": "Custom field 3", + "UnitCustom4": "Custom field 4", + "UnitCustom5": "Custom field 5", + "UnitCustom6": "Custom field 6", + "UnitCustom7": "Custom field 7", + "UnitCustom8": "Custom field 8", + "UnitCustom9": "Custom field 9", + "UnitDescription": "Description", + "UnitLastMeter": "Last meter reading", + "UnitLifeSpan": "Life span", + "UnitList": "Units", + "UnitMetered": "Unit Metered", + "UnitNotes": "Notes", + "UnitOverrideLength": "Override Length", + "UnitOverrideLifeTime": "Override LifeTime", + "UnitOverrideWarranty": "Override Warranty", + "UnitOverrideWarrantyExpiryDate": "Overriden Warranty Expiry Date", + "UnitOverrideWarrantyTerms": "Override Warranty Terms", + "UnitParentUnitID": "Parent Unit of this Unit", + "UnitPurchasedDate": "Purchased Date", + "UnitPurchaseFromID": "Purchased From", + "UnitReceipt": "Receipt Number", + "UnitReplacedByUnitID": "Replaced by Unit", + "UnitSerial": "Serial Number", + "UnitText1": "Text1", + "UnitText2": "Text2", + "UnitText3": "Text3", + "UnitText4": "Text4", + "UnitUINotWarrantiedDisplay": "Unit is not warrantied", + "UnitUIWarrantiedDisplay": "Unit is warrantied until {0}\r\n\r\nWarranty terms:\r\n=-=-=-=-=-=-=-=-\r\n{1}", + "UnitUIWarrantyExpiredDisplay": "Unit's warranty expired {0}", + "UnitUnitHasOwnAddress": "Unit Has Own Address", + "UnitWorkorderLastServicedID": "Workorder Last Serviced", + "UnitMeterReadingDescription": "Meter description", + "UnitMeterReadingList": "Unit Meter Reading List", + "UnitMeterReadingMeter": "Unit Meter Reading", + "UnitMeterReadingMeterDate": "Meter reading date", + "UnitMeterReadingWorkorderItemID": "Meter read on workorder", + "UnitModelCustom0": "Custom field 0", + "UnitModelCustom1": "Custom field 1", + "UnitModelCustom2": "Custom field 2", + "UnitModelCustom3": "Custom field 3", + "UnitModelCustom4": "Custom field 4", + "UnitModelCustom5": "Custom field 5", + "UnitModelCustom6": "Custom field 6", + "UnitModelCustom7": "Custom field 7", + "UnitModelCustom8": "Custom field 8", + "UnitModelCustom9": "Custom field 9", + "UnitModelDiscontinued": "Discontinued", + "UnitModelDiscontinuedDate": "Discontinued Date", + "UnitModelIntroducedDate": "Introduced Date", + "UnitModelLifeTimeWarranty": "Life Time Warranty", + "UnitModelList": "Unit Models", + "UnitModelModelNumber": "Model Number", + "UnitModelName": "Unit Model Name", + "UnitModelNotes": "Notes", + "UnitModelUPC": "UPC", + "UnitModelVendorID": "Unit model vendor", + "UnitModelWarrantyLength": "Warranty Length", + "UnitModelWarrantyTerms": "Warranty Terms", + "UnitModelCategoryDescription": "Description", + "UnitModelCategoryList": "Unit Model Categories", + "UnitModelCategoryName": "Unit Model Category Name", + "UnitNameDisplayFormatsModelModelNumberSerial": "Model name, model number, serial number", + "UnitNameDisplayFormatsModelNumberModelSerial": "Model number, model name, serial number", + "UnitNameDisplayFormatsModelSerial": "Model name, serial number", + "UnitNameDisplayFormatsSerialDescription": "Serial number, description", + "UnitNameDisplayFormatsSerialModel": "Serial number, model name", + "UnitNameDisplayFormatsSerialModelVendor": "Serial number, model name, vendor", + "UnitNameDisplayFormatsSerialOnly": "Serial number", + "UnitNameDisplayFormatsVendorModelModelNumberSerial": "Vendor, model name, model number, serial number", + "UnitNameDisplayFormatsVendorModelSerial": "Vendor, model name, serial number", + "UnitNameDisplayFormatsVendorSerial": "Vendor - Serial number", + "UnitNameDisplayFormatsVendorSerialDescription": "Vendor, serial number, description", + "UnitOfMeasureList": "Units of Measure", + "UnitOfMeasureName": "Unit of Measure Name", + "UnitServiceTypeDescription": "Description", + "UnitServiceTypeList": "Unit Service Types", + "UnitServiceTypeName": "Name", + "UserCustom0": "Custom field 0", + "UserCustom1": "Custom field 1", + "UserCustom2": "Custom field 2", + "UserCustom3": "Custom field 3", + "UserCustom4": "Custom field 4", + "UserCustom5": "Custom field 5", + "UserCustom6": "Custom field 6", + "UserCustom7": "Custom field 7", + "UserCustom8": "Custom field 8", + "UserCustom9": "Custom field 9", + "UserDefaultWarehouseID": "Default warehouse", + "UserEmailAddress": "User Email Address", + "UserEmployeeNumber": "Employee Number", + "UserErrorNotSelectable": "Selected user is not active or not a scheduleable user", + "UserEventQuickNotification": "Quick Notification", + "UserFirstName": "First Name", + "UserInitials": "Initials", + "UserLastName": "Last Name", + "UserList": "Users", + "UserLogin": "Login Name", + "UserMemberOfGroup": "Security group", + "UserMustBeActive": "This user must be active as it has open schedule items", + "UserMustBeScheduleable": "This user must be a Scheduleable User type to preserve data history", + "UserNotes": "Notes", + "UserPageAddress": "Pager Address", + "UserPageMaxText": "Pager max text", + "UserPassword": "Password", + "UserPhone1": "Phone1", + "UserPhone2": "Phone 2", + "UserScheduleBackColor": "Schedule back color", + "UserStatus": "Status", + "UserSubContractor": "Is a Subcontractor", + "UserTimeZoneOffset": "Override timezone", + "UserUIClearAllLayoutCustomization": "Clear all user's form customizations", + "UserUserCertifications": "Certifications", + "UserUserSkills": "Skills", + "UserUserType": "User type", + "UserVendorID": "Subcontractor Vendor", + "UserCertificationDescription": "Description", + "UserCertificationList": "User Certifications", + "UserCertificationName": "User Certification Name", + "UserCertificationAssignedValidStartDate": "Valid Start Date", + "UserCertificationAssignedValidStopDate": "Valid Stop Date", + "UserRightList": "Member rights", + "UserRightRight": "Internal Object", + "UserRightSecurityLevel": "Security Level", + "UserSkillDescription": "Description", + "UserSkillList": "User Skills", + "UserSkillName": "User Skill Name", + "UserTypesAdministrator": "Administrator user", + "UserTypesClient": "Client user", + "UserTypesHeadOffice": "Head office client user", + "UserTypesNonSchedulable": "Non-schedulable user", + "UserTypesSchedulable": "Schedulable user", + "UserTypesUtilityNotification": "Notification server account", + "VendorAccountNumber": "Account Number", + "VendorContact": "Contact", + "VendorContactNotes": "Other contacts", + "VendorCustom0": "Custom field 0", + "VendorCustom1": "Custom field 1", + "VendorCustom2": "Custom field 2", + "VendorCustom3": "Custom field 3", + "VendorCustom4": "Custom field 4", + "VendorCustom5": "Custom field 5", + "VendorCustom6": "Custom field 6", + "VendorCustom7": "Custom field 7", + "VendorCustom8": "Custom field 8", + "VendorCustom9": "Custom field 9", + "VendorEmail": "Email", + "VendorList": "Vendors", + "VendorName": "Vendor Name", + "VendorNotes": "Notes", + "VendorPhone1": "Business", + "VendorPhone2": "Fax", + "VendorPhone3": "Home", + "VendorPhone4": "Mobile", + "VendorPhone5": "Pager", + "VendorVendorType": "Vendor Type", + "VendorVendorTypeManufacturer": "Manufacturer", + "VendorVendorTypeShipper": "Shipper", + "VendorVendorTypeSubContractor": "SubContractor", + "VendorVendorTypeThirdPartyRepair": "Third Party Repair", + "VendorVendorTypeWholesaler": "Wholesaler", + "WikiPageInternalOnly": "Internal users only", + "WikiPageTitle": "Title", + "WorkorderClosed": "Closed", + "WorkorderConvertScheduledUserToLabor": "Convert scheduled user to labor", + "WorkorderCopyWorkorderItem": "Copy selected workorder item to an existing workorder for this client", + "WorkorderMoveWorkorderItem": "Move workorder item to a different work order", + "WorkorderCustomerContactName": "Contact", + "WorkorderCustomerReferenceNumber": "Client Reference #", + "WorkorderDeleted": "Deleted", + "WorkorderClosedIsPermanent": "A closed work order can never be set to open status", + "WorkorderDeleteLastWorkorderItem": "A work order must always have at least one work order item", + "WorkorderDirtyOrBrokenRules": "This operation can not be completed - workorder is not saved or has broken rules", + "WorkorderLoanItemsNotReturned": "This operation can not be completed - One or more loaned items are not yet returned", + "WorkorderNotCloseableDueToErrors": "This work order can not be closed due to one or more broken rules", + "WorkorderNotCompleteableDueToErrors": "This work order can not be set to service completed due to one or more broken rules", + "WorkorderPartRequestsOnOrder": "This operation can not be completed - One or more part requests are still on order and not yet received", + "WorkorderPartRequestsUnOrdered": "This operation can not be completed - One or more unordered part requests need to be removed first", + "WorkorderSourceInvalidType": "Source workorder type is not valid", + "WorkorderEventCloseByDatePassed": "Workorder - \"Close By\" date passed", + "WorkorderEventQuoteUpdated": "Quote - created / updated", + "WorkorderEventStatus": "Workorder - \"Status\" changed", + "WorkorderFormLayoutID": "Form layout ID", + "WorkorderFromPMID": "P.M. parent", + "WorkorderFromQuoteID": "Quote parent", + "WorkorderGenerateUnit": "Generate unit from selected part", + "WorkorderInternalReferenceNumber": "Internal Reference #", + "WorkorderListAll": "List all work orders", + "WorkorderOnsite": "Onsite", + "WorkorderServiceCompleted": "Service Completed", + "WorkorderSign": "Sign", + "WorkorderSummary": "Summary", + "WorkorderTemplate": "Template", + "WorkorderTemplateDescription": "Template description", + "WorkorderTemplateFreshPrice": "Use current Part prices on generated order", + "WorkorderTemplateID": "Template ID", + "WorkorderWarningClosedChanged": "Warning: If you continue the workorder will be closed permanently. \r\n\r\nAre you sure?", + "WorkorderWarningNotAllPartsUsed": "Warning: One or more parts on this workorder are not set to USED status.\r\n\r\nIf you continue all parts will be set to USED status automatically.\r\n\r\nAre you sure?", + "WorkorderWarningServiceCompletedChanged": "Warning: If you continue this form will be closed and this work order's service completed status changed. \r\n \r\nAre you sure?", + "WorkorderWorkorderItems": "Workorder Items", + "WorkorderCategoryDescription": "Description", + "WorkorderCategoryList": "Workorder Categories", + "WorkorderCategoryName": "Workorder Category Name", + "WorkorderDetailsList": "Workorder Details", + "WorkorderItemCustom0": "Custom field 0", + "WorkorderItemCustom1": "Custom field 1", + "WorkorderItemCustom2": "Custom field 2", + "WorkorderItemCustom3": "Custom field 3", + "WorkorderItemCustom4": "Custom field 4", + "WorkorderItemCustom5": "Custom field 5", + "WorkorderItemCustom6": "Custom field 6", + "WorkorderItemCustom7": "Custom field 7", + "WorkorderItemCustom8": "Custom field 8", + "WorkorderItemCustom9": "Custom field 9", + "WorkorderItemCustomFields": "Custom Fields", + "WorkorderItemEventNotServiced": "Workorder item - not serviced quickly enough", + "WorkorderItemExpenses": "Expenses", + "WorkorderItemLabors": "Labor", + "WorkorderItemList": "Items", + "WorkorderItemLoans": "Loans", + "WorkorderItemOutsideService": "Outside Service", + "WorkorderItemPartRequests": "Part requests", + "WorkorderItemParts": "Parts", + "WorkorderItemPriorityID": "Priority", + "WorkorderItemRequestDate": "Request Date", + "WorkorderItemScheduledUsers": "Scheduled Users", + "WorkorderItemSummary": "Item Summary", + "WorkorderItemTaskListID": "Task List", + "WorkorderItemTasks": "Tasks", + "WorkorderItemTechNotes": "Service Notes", + "WorkorderItemTravels": "Travel", + "WorkorderItemTypeID": "Workorder Item Type", + "WorkorderItemWarrantyService": "Warranty Service", + "WorkorderItemWorkorderStatusID": "Workorder item status", + "WorkorderItemLaborLaborBanked": "Banked", + "WorkorderItemLaborLaborRateCharge": "Rate Charge", + "WorkorderItemLaborList": "Labor Items", + "WorkorderItemLaborNoChargeQuantity": "No Charge Quantity", + "WorkorderItemLaborServiceDetails": "Service Details", + "WorkorderItemLaborServiceRateID": "Service Rate", + "WorkorderItemLaborServiceRateQuantity": "Service Rate Quantity", + "WorkorderItemLaborServiceStartDate": "Service Start Date & Time", + "WorkorderItemLaborServiceStopDate": "Service Stop Date & Time", + "WorkorderItemLaborTaxCodeID": "Tax Code", + "WorkorderItemLaborTaxRateSaleID": "Sales tax", + "WorkorderItemLaborUIBankWarning": "Are you sure you want to Bank this record?\r\n(Once this record is banked it will be locked and can no longer be edited)", + "WorkorderItemLaborUIReBankWarning": "This item is already banked", + "WorkorderItemLaborUserID": "User", + "WorkorderItemLoanCharges": "Charges", + "WorkorderItemLoanDueDate": "Due back", + "WorkorderItemLoanList": "Loan Items", + "WorkorderItemLoanLoanItem": "Loan Item", + "WorkorderItemLoanLoanItemID": "Loan item", + "WorkorderItemLoanLoanTaxA": "Tax A", + "WorkorderItemLoanLoanTaxAExempt": "Tax A Exempt", + "WorkorderItemLoanLoanTaxB": "Tax B", + "WorkorderItemLoanLoanTaxBExempt": "Tax B Exempt", + "WorkorderItemLoanLoanTaxOnTax": "Tax On Tax", + "WorkorderItemLoanLoanTaxRateSale": "Tax", + "WorkorderItemLoanNotes": "Notes", + "WorkorderItemLoanOutDate": "Loaned", + "WorkorderItemLoanQuantity": "Rate quantity", + "WorkorderItemLoanRate": "Rate", + "WorkorderItemLoanRateAmount": "Rate amount", + "WorkorderItemLoanReturnDate": "Returned", + "WorkorderItemLoanTaxCodeID": "Sales tax", + "WorkorderItemMiscExpenseChargeAmount": "Charge Amount", + "WorkorderItemMiscExpenseChargeTaxCodeID": "Charge Tax Code", + "WorkorderItemMiscExpenseChargeToClient": "Charge to Client?", + "WorkorderItemMiscExpenseDescription": "Description", + "WorkorderItemMiscExpenseTaxA": "Misc Exp Tax A Value", + "WorkorderItemMiscExpenseTaxAExempt": "Misc Exp Tax A Exempt", + "WorkorderItemMiscExpenseTaxB": "Misc Exp Tax B Value", + "WorkorderItemMiscExpenseTaxBExempt": "Misc Exp Tax B Exempt", + "WorkorderItemMiscExpenseTaxOnTax": "Misc Exp Tax On Tax", + "WorkorderItemMiscExpenseTaxRateSale": "Misc Exp Tax Rate", + "WorkorderItemMiscExpenseList": "Misc Expense Items", + "WorkorderItemMiscExpenseName": "Misc Exp Summary", + "WorkorderItemMiscExpenseReimburseUser": "Reimburse User?", + "WorkorderItemMiscExpenseTaxPaid": "Tax Paid", + "WorkorderItemMiscExpenseTotalCost": "Total Cost", + "WorkorderItemMiscExpenseUser": "User", + "WorkorderItemMiscExpenseUserID": "User", + "WorkorderItemOutsideServiceDateETA": "ETA Date", + "WorkorderItemOutsideServiceDateReturned": "Date Returned", + "WorkorderItemOutsideServiceDateSent": "Date Sent", + "WorkorderItemOutsideServiceEventUnitBackFromService": "Workorder item outside service - unit received back", + "WorkorderItemOutsideServiceEventUnitNotBackFromServiceByETA": "Workorder item outside service - unit is overdue", + "WorkorderItemOutsideServiceNotes": "Notes", + "WorkorderItemOutsideServiceReceivedBack": "Received Back", + "WorkorderItemOutsideServiceRepairCost": "Repair Cost", + "WorkorderItemOutsideServiceRepairPrice": "Repair Price", + "WorkorderItemOutsideServiceRMANumber": "RMA Number", + "WorkorderItemOutsideServiceSenderUserID": "Shipped by", + "WorkorderItemOutsideServiceShippingCost": "Shipping Cost", + "WorkorderItemOutsideServiceShippingPrice": "Shipping Price", + "WorkorderItemOutsideServiceTrackingNumber": "Tracking Number", + "WorkorderItemOutsideServiceVendorSentToID": "Sent To", + "WorkorderItemOutsideServiceVendorSentViaID": "Sent Via", + "WorkorderItemPartDescription": "Description", + "WorkorderItemPartDiscount": "Discount", + "WorkorderItemPartDiscountType": "Discount Type", + "WorkorderItemPartHasAffectedInventory": "Has affected inventory", + "WorkorderItemPartList": "Part Items", + "WorkorderItemPartPartID": "Part", + "WorkorderItemPartPartSerialID": "Serial Number", + "WorkorderItemPartPartWarehouseID": "Warehouse", + "WorkorderItemPartPrice": "Price", + "WorkorderItemPartQuantity": "Quantity", + "WorkorderItemPartQuantityReserved": "Pre-selected quantity", + "WorkorderItemPartTaxPartSaleID": "Sales tax", + "WorkorderItemPartUIQuantityReservedPM": "Quantity required", + "WorkorderItemPartUIQuantityReservedQuote": "Quantity quoted", + "WorkorderItemPartUsed": "Used in service", + "WorkorderItemPartWarningInsufficientStock": "Insufficient stock ({0:N}).\r\nDo you want to request {1:N}?", + "WorkorderItemPartWarningPartNotFound": "Part not found in parts list", + "WorkorderItemPartRequestNotDeleteableOnOrder": "A workorder item part request can not be deleted when parts are on order. Once parts are received it may be deleted.", + "WorkorderItemPartRequestEventPartsReceived": "Workorder item part request - PartsReceived", + "WorkorderItemPartRequestList": "Part requests", + "WorkorderItemPartRequestOnOrder": "On order", + "WorkorderItemPartRequestPartID": "Part", + "WorkorderItemPartRequestPartWarehouseID": "Warehouse", + "WorkorderItemPartRequestQuantity": "Quantity", + "WorkorderItemPartRequestReceived": "Received", + "WorkorderItemScheduledUserRecordIncomplete": "Nothing to schedule", + "WorkorderItemScheduledUserEstimatedQuantity": "Estimated quantity", + "WorkorderItemScheduledUserEventCreatedUpdated": "Work order item scheduled user - (created / updated)", + "WorkorderItemScheduledUserEventPendingAlert": "Work order item scheduled user - Event imminent", + "WorkorderItemScheduledUserList": "Scheduled User Items", + "WorkorderItemScheduledUserServiceRateID": "Suggested rate", + "WorkorderItemScheduledUserStartDate": "Start Date & Time", + "WorkorderItemScheduledUserStartDateRelative": "Start (relative)", + "WorkorderItemScheduledUserStopDate": "Stop Date & Time", + "WorkorderItemScheduledUserUserID": "User", + "WorkorderItemScheduledUserWarnOutOfRegion": "Warning: User is not in client's region - won't see this item", + "WorkorderItemTaskCompletionTypeComplete": "Completed", + "WorkorderItemTaskCompletionTypeIncomplete": "To Do", + "WorkorderItemTaskCompletionTypeNotApplicable": "N/A", + "WorkorderItemTaskObject": "Workorder item task", + "WorkorderItemTaskTaskID": "Task", + "WorkorderItemTaskWorkorderItemTaskCompletionType": "Status", + "WorkorderItemTravelDistance": "Distance", + "WorkorderItemTravelList": "Travel Items", + "WorkorderItemTravelNoChargeQuantity": "No Charge Quantity", + "WorkorderItemTravelNotes": "Notes", + "WorkorderItemTravelServiceRateID": "Travel Rate", + "WorkorderItemTravelTaxCodeID": "Tax Code", + "WorkorderItemTravelTaxRateSaleID": "Sales tax", + "WorkorderItemTravelDetails": "Travel Details", + "WorkorderItemTravelRateCharge": "Travel Rate Charge", + "WorkorderItemTravelRateID": "Travel Rate", + "WorkorderItemTravelRateQuantity": "Quantity", + "WorkorderItemTravelStartDate": "Start Date", + "WorkorderItemTravelStopDate": "Stop Date", + "WorkorderItemTravelUserID": "User", + "WorkorderItemTypeDescription": "Description", + "WorkorderItemTypeList": "Workorder Item Types", + "WorkorderItemTypeName": "Workorder Item Type Name", + "WorkorderPreventiveMaintenanceDayOfTheWeek": "Desired Day of the Week", + "WorkorderPreventiveMaintenanceGenerateServiceWorkorder": "Manually Generate Service Workorder", + "WorkorderPreventiveMaintenanceGenerateSpan": "Generate time span", + "WorkorderPreventiveMaintenanceGenerateSpanUnit": "Generate", + "WorkorderPreventiveMaintenanceList": "Preventive maintenance", + "WorkorderPreventiveMaintenanceNextServiceDate": "Next service date", + "WorkorderPreventiveMaintenanceStopGeneratingDate": "Stop generating date", + "WorkorderPreventiveMaintenanceThresholdSpan": "Threshold time span", + "WorkorderPreventiveMaintenanceThresholdSpanUnit": "Threshold", + "WorkorderPreventiveMaintenanceByUnitList": "Preventive Maintenance By Unit", + "WorkorderQuoteDateApproved": "Approved", + "WorkorderQuoteDateSubmitted": "Submitted", + "WorkorderQuoteGenerateServiceWorkorder": "Generate Service Workorder from this Quote", + "WorkorderQuoteIntroduction": "Introductory Text", + "WorkorderQuoteList": "Quotes", + "WorkorderQuotePreparedByID": "Prepared by User", + "WorkorderQuoteQuoteNumber": "Quote Number", + "WorkorderQuoteQuoteRequestDate": "Requested", + "WorkorderQuoteQuoteStatusType": "Status", + "WorkorderQuoteServiceWorkorderID": "Service Workorder", + "WorkorderQuoteValidUntilDate": "Valid Until", + "WorkorderQuoteStatusTypesAwarded": "Awarded", + "WorkorderQuoteStatusTypesInProgress": "In progress", + "WorkorderQuoteStatusTypesNew": "New", + "WorkorderQuoteStatusTypesNotAwarded": "Not awarded", + "WorkorderQuoteStatusTypesNotAwarded2": "Beyond economical repair", + "WorkorderQuoteStatusTypesSubmitted": "Submitted, waiting", + "WorkorderServiceAge": "Age", + "WorkorderServiceClientRequestID": "Client Request Reference", + "WorkorderServiceCloseByDate": "Close by date", + "WorkorderServiceInvoiceNumber": "Invoice Number", + "WorkorderServiceList": "Service Workorders", + "WorkorderServiceQuoteWorkorderID": "Quote", + "WorkorderServiceServiceDate": "Service Date", + "WorkorderServiceServiceDateRelative": "Service date (relative)", + "WorkorderServiceServiceNumber": "Service Number", + "WorkorderServiceWorkorderPreventiveMaintenanceWorkorderID": "Preventive Maintenance", + "WorkorderStatusARGB": "ARGB color", + "WorkorderStatusBold": "Bold", + "WorkorderStatusCompletedStatus": "This status is \"Completed\"", + "WorkorderStatusList": "Workorder Statuses", + "WorkorderStatusName": "Workorder Status Name", + "WorkorderStatusUnderlined": "Underlined", + "WorkorderSummaryTemplate": "Workorder Item Summary Template", + "WorkorderSummaryWorkorderItem": "Workorder Item Info To Display" +} \ No newline at end of file diff --git a/server/AyaNova/resource/es.json b/server/AyaNova/resource/es.json new file mode 100644 index 00000000..092927cc --- /dev/null +++ b/server/AyaNova/resource/es.json @@ -0,0 +1,1415 @@ +{ + "AddressType": "Tipo de dirección", + "AddressTypePhysical": "Dirección física", + "AddressTypePhysicalDescription": "Esta es la dirección donde se encuentra físicamente el edificio y donde se entregan los artículos", + "AddressTypePostal": "Dirección postal", + "AddressTypePostalDescription": "Esta es la dirección a la que se enviaría el correo", + "AddressCity": "Ciudad", + "AddressCopyToPhysical": "Copy to physical address", + "AddressCopyToPostal": "Copy to postal address", + "AddressCountry": "País", + "AddressCountryCode": "Prefijo país", + "AddressDeliveryAddress": "Calle", + "AddressFullAddress": "Dirección completa", + "AddressLatitude": "Latitud", + "AddressLongitude": "Longitud", + "AddressMapQuestURL": "Enlace web de mapas MapQuest", + "AddressPostal": "Código postal", + "AddressPostalCity": "Ciudad (correo)", + "AddressPostalCountry": "País (correo)", + "AddressPostalDeliveryAddress": "Dirección (correo)", + "AddressPostalPostal": "Código postal (correo)", + "AddressPostalStateProv": "Provincia (correo)", + "AddressStateProv": "Provincia", + "AdminEraseDatabase": "Borrar toda la base de datos AyaNova", + "AdminEraseDatabaseLastWarning": "Advertencia: Esta es su última oportunidad para evitar borrar todos los datos permanentemente. ¿Seguro que desea borrar todos los datos?", + "AdminEraseDatabaseWarning": "Advertencia: está a punto de borrar de forma permanente todos los datos de AyaNova. ¿Está seguro?", + "AdminPasteLicense": "Pegar clave de licencia", + "AssignedDocDescription": "Descripción", + "AssignedDocList": "Documentos", + "AssignedDocURL": "Enlace documento", + "AyaFileFileTooLarge": "File size exceeds limit of {0}", + "AyaFileFileSize": "Size", + "AyaFileFileSizeStored": "Size stored", + "AyaFileFileType": "Type", + "AyaFileList": "Files in database", + "AyaFileSource": "Source", + "ClientAccountNumber": "Número de cuenta", + "ClientBillHeadOffice": "Sede para facturación", + "ClientContact": "Contact", + "ClientContactNotes": "Other contacts", + "ClientCustom0": "Campo personalizado 0", + "ClientCustom1": "Campo personalizado 1", + "ClientCustom2": "Campo personalizado 2", + "ClientCustom3": "Campo personalizado 3", + "ClientCustom4": "Campo personalizado 4", + "ClientCustom5": "Campo personalizado 5", + "ClientCustom6": "Campo personalizado 6", + "ClientCustom7": "Campo personalizado 7", + "ClientCustom8": "Campo personalizado 8", + "ClientCustom9": "Campo personalizado 9", + "ClientEmail": "Email", + "ClientEventContractExpire": "Cliente - fin de contrato", + "ClientList": "Clientes", + "ClientName": "Nombre del cliente", + "ClientNotes": "Notas generales", + "ClientNotification": "Send client notifications", + "ClientPhone1": "Business", + "ClientPhone2": "Fax", + "ClientPhone3": "Home", + "ClientPhone4": "Mobile", + "ClientPhone5": "Pager", + "ClientPopUpNotes": "Notas desplegables", + "ClientTechNotes": "Notas usuario programable", + "ClientGroupDescription": "Descripción", + "ClientGroupList": "Grupos de clientes", + "ClientGroupName": "Nombre grupo de clientes", + "ClientNoteClientNoteTypeID": "Tipo nota de cliente", + "ClientNoteList": "Notas cliente", + "ClientNoteNoteDate": "Fecha nota", + "ClientNoteNotes": "Notas", + "ClientNoteTypeList": "Tipos de notas de cliente", + "ClientNoteTypeName": "Nombre tipo de nota de cliente", + "ClientRequestPartClientServiceRequestItemID": "Elemento solicitud servicio cliente", + "ClientRequestPartPrice": "Precio", + "ClientRequestPartQuantity": "Cantidad", + "ClientRequestTechClientServiceRequestItemID": "Elemento solicitud servicio cliente", + "ClientRequestTechScheduledStartDate": "Fecha de inicio programada solicitada", + "ClientRequestTechScheduledStopDate": "Fecha final programada solicitada", + "ClientRequestTechUserID": "Usuario programable solicitado", + "ClientServiceRequestAcceptToExisting": "Accept to existing work order", + "ClientServiceRequestAcceptToNew": "Accept to new work order", + "ClientServiceRequestCustomContactName": "Nombre del contacto", + "ClientServiceRequestCustomerReferenceNumber": "Número de referencia", + "ClientServiceRequestDetailedServiceToBePerformed": "Detalles del servicio a realizar", + "ClientServiceRequestDetails": "Details", + "ClientServiceRequestEventCreated": "Client service request - New", + "ClientServiceRequestEventCreatedUpdated": "Solicitud de servicio del cliente - nuevo / actualizado", + "ClientServiceRequestList": "Customer service requests", + "ClientServiceRequestOnsite": "En el lugar", + "ClientServiceRequestParts": "Piezas", + "ClientServiceRequestPreferredTechs": "Usuarios programables solicitados", + "ClientServiceRequestPriority": "Prioridad", + "ClientServiceRequestReject": "Reject service request", + "ClientServiceRequestRequestedBy": "Requested by", + "ClientServiceRequestStatus": "Status", + "ClientServiceRequestTitle": "Title", + "ClientServiceRequestWorkorderItems": "Elementos del servicio solicitado", + "ClientServiceRequestItemServiceToBePerformed": "Resumen del servicio a realizar", + "ClientServiceRequestItemUnitID": "Unidad", + "ClientServiceRequestPriorityASAP": "ASAP", + "ClientServiceRequestPriorityEmergency": "Emergency", + "ClientServiceRequestPriorityNotUrgent": "Not urgent", + "ClientServiceRequestStatusAccepted": "Accepted", + "ClientServiceRequestStatusClosed": "Closed", + "ClientServiceRequestStatusDeclined": "Declined", + "ClientServiceRequestStatusOpen": "Open", + "CommonActive": "Activo", + "CommonContractExpires": "El contrato expira", + "CommonCost": "Coste", + "CommonCreated": "Registro creado", + "CommonCreator": "Registro creado por", + "CommonDefaultLanguage": "Idioma por omisión", + "CommonDescription": "Descripción", + "CommonID": "Número de identificación único", + "CommonModified": "Registro modificado", + "CommonModifier": "Registro modificado por", + "CommonMore": "More...", + "CommonName": "Nombre", + "CommonRootObject": "Objeto raíz", + "CommonRootObjectType": "Tipo de objeto raíz", + "CommonSerialNumber": "Número de serie", + "CommonUsesBanking": "Pago por adelantado", + "CommonWebAddress": "Dirección web", + "ContactContactTitleID": "Tratamiento", + "ContactDescription": "Descripción", + "ContactEmailAddress": "Dirección de e-mail", + "ContactFirstName": "Nombre de pila", + "ContactFullContact": "Contacto completo", + "ContactJobTitle": "Cargo", + "ContactLastName": "Apellido", + "ContactPhones": "Teléfonos", + "ContactPrimaryContact": "Contacto principal", + "ContactRootObjectID": "ID objeto raíz", + "ContactRootObjectType": "Tipo de objeto raíz", + "ContactPhoneContactID": "Contacto", + "ContactPhoneType": "Tipo de teléfono del contacto", + "ContactPhoneTypeBusiness": "Trabajo", + "ContactPhoneTypeFax": "Fax", + "ContactPhoneTypeHome": "Domicilio", + "ContactPhoneTypeMobile": "Móvil", + "ContactPhoneTypePager": "Buscapersonas", + "ContactPhoneFullPhoneRecord": "Teléfono completo", + "ContactPhoneAreaCode": "Prefijo regional", + "ContactPhoneCountryCode": "Prefijo país", + "ContactPhoneDefault": "Teléfono por omisión", + "ContactPhoneExtension": "Extensión", + "ContactPhoneNumber": "Teléfono", + "ContactPhoneTypeName": "Nombre tipo de teléfono", + "ContactPhoneTypeObjectName": "Tipo", + "ContactTitleList": "Tratamientos contacto", + "ContactTitleName": "Tratamiento Nombre", + "ContractContractRatesOnly": "Limitar a tarifas de contrato", + "ContractCustom0": "Campo personalizado 0", + "ContractCustom1": "Campo personalizado 1", + "ContractCustom2": "Campo personalizado 2", + "ContractCustom3": "Campo personalizado 3", + "ContractCustom4": "Campo personalizado 4", + "ContractCustom5": "Campo personalizado 5", + "ContractCustom6": "Campo personalizado 6", + "ContractCustom7": "Campo personalizado 7", + "ContractCustom8": "Campo personalizado 8", + "ContractCustom9": "Campo personalizado 9", + "ContractDiscountParts": "Descuento aplicado a todas las piezas", + "ContractList": "Contratos", + "ContractName": "Nombre del contrato", + "ContractNotes": "Notas", + "ContractRateList": "Tarifas de contrato", + "ContractRatesRateID": "Tarifas", + "CoordinateTypesDecimalDegrees": "Grados decimales (GGG,ggg°)", + "CoordinateTypesDegreesDecimalMinutes": "Grados minutos (GGG° MM,mmm)", + "CoordinateTypesDegreesMinutesSeconds": "Grados Minutos Segundos (GGG° MM' SS,sss')", + "CustomFieldKey": "Clave campo personalizado", + "DashboardDashboard": "Dashboard", + "DashboardNext": "Next", + "DashboardNotAssigned": "Not assigned", + "DashboardOverdue": "Overdue", + "DashboardReminders": "Reminders", + "DashboardScheduled": "Scheduled", + "DispatchZoneDescription": "Descripción", + "DispatchZoneList": "Zonas de reparto", + "DispatchZoneName": "Nombre zona de reparto", + "ErrorAutoIncrementNumberTooLow": "Error: El nuevo número debe ser por lo menos {0} para no entrar en conflicto con los registros existentes", + "ErrorDBFetchError": "Error en la base de datos. No ha podido recuperarse el registro: {0}", + "ErrorDBForeignKeyViolation": "Este objeto no puede borrarse porque está vinculado con uno o más objetos relacionados", + "ErrorDBRecordModifiedExternally": "Error en la base de datos: el usuario {1} ha modificado un registro de la tabla {0} después de que usted lo abriera y no puede actualizarse en este momento.\\r\\nDebe cerrar el registro y volverlo a abrir para poder modificarlo o borrarlo.", + "ErrorDBSchemaMismatch": "Error: Este programa requiere la versión {0} de la base de datos; la versión abierta es la {1}", + "ErrorGridFilterByOtherColumnNotSupported": "No se puede filtrar comparando con valores de otras columnas ({0})", + "ErrorLicenseExpired": "La licencia de AyaNova ha caducado. Se limitará el uso a sólo lectura a todos los usuarios hasta que se introduzca una clave de licencia válida.\r\n\r\nLas licencias pueden adquirirse fácilmente y a precios asequibles; consulte nuestra web www.ayanova.com.", + "ErrorLicenseWillExpire": "Advertencia: esta licencia expirará el {0}", + "ErrorLiteDatabase": "Error: AyaNova Lite can only be used with a standalone FireBird database", + "ErrorDuplicateNameWarning": "Warning: There is an existing item in the database with the same name", + "ErrorDuplicateSerialWarning": "Warning: There is an existing item in the database with the same serial number", + "ErrorFieldLengthExceeded": "{0} no puede pasar de {1} caracteres", + "ErrorFieldLengthExceeded255": "{0} sobrepasa el límite de 255 caracteres", + "ErrorFieldLengthExceeded500": "{0} sobrepasa el límite de 500 caracteres", + "ErrorFieldValueNotBetween": "{0} no válido; debe estar entre {1} y {2}", + "ErrorFieldValueNotValid": "{0} no es válido", + "ErrorNameFetcherNotFound": "Recopilador nombre/bool: ¡No se encuentra el campo {0} de la tabla {1} con el ID de registro {2}!", + "ErrorNotChangeable": "Error: no puede cambiarse un objeto de tipo {0}", + "ErrorNotDeleteable": "Error: no puede borrarse un objeto de tipo {0}", + "ErrorRequiredFieldEmpty": "{0} es un campo necesario. Introduzca un valor para {0}", + "ErrorStartDateAfterEndDate": "La fecha de inicio debe ser anterior a la de fin", + "ErrorSecurityAdministratorOnlyMessage": "Debe iniciar la sesión como Administrador para acceder a esta función", + "ErrorSecurityNotAuthorizedToChange": "Error: El usuario no está autorizado a cambiar un objeto de tipo {0} o el objeto o campo que se está modificando sólo es de lectura.", + "ErrorSecurityNotAuthorizedToCreate": "Error: El usuario actual no está autorizado a crear un objeto nuevo de tipo {0}", + "ErrorSecurityNotAuthorizedToDelete": "Error: El usuario actual no está autorizado a borrar un objeto de tipo {0}", + "ErrorSecurityNotAuthorizedToDeleteDefaultObject": "Error: El objeto de tipo {0} por omisión no puede borrarse", + "ErrorSecurityNotAuthorizedToRetrieve": "Error: El usuario actual no está autorizado a abrir un registro de {0}", + "ErrorSecurityUserCapacity": "No hay suficientes licencias disponibles para continuar con esta operación", + "ErrorTrialRestricted": "El modo de prueba está restringido a un máximo de 30 pedidos; deberá borrar un pedido para poder añadir otro nuevo.\r\n\r\nLas licencias pueden adquirirse a un precio asequible y de manera rápida y fácil; consulte nuestra web www.ayanova.com.", + "ErrorUnableToOpenDocumentUrl": "No puede abrirse el documento", + "ErrorUnableToOpenEmailUrl": "No puede abrirse la dirección de e-mail", + "ErrorUnableToOpenWebUrl": "No puede abrirse la dirección web", + "FormFieldDataTypesCurrency": "Dinero", + "FormFieldDataTypesDateOnly": "Fecha", + "FormFieldDataTypesDateTime": "Fecha y hora", + "FormFieldDataTypesNumber": "Número", + "FormFieldDataTypesText": "Texto", + "FormFieldDataTypesTimeOnly": "Hora", + "FormFieldDataTypesTrueFalse": "Verdadero/falso", + "GlobalAllowScheduleConflicts": "Permitir conflictos de programación", + "GlobalAllowScheduleConflictsDescription": "Si el usuario que asigna las planificaciones desea que se le avise si los horarios programados se solapan, debe ajustar esto a Falso. Si es habitual que los horarios se solapen, se recomienda ajustar a Verdadero", + "GlobalCJKIndex": "Usar índice asiático", + "GlobalCJKIndexDescription": "Ajustar a verdadero sólo si hay entradas con caracteres chinos, japoneses o coreanos en los campos y etiquetas", + "GlobalCoordinateStyle": "Estilo de visualización de coordenadas", + "GlobalCoordinateStyleDescription": "Define cómo se muestran las coordenadas geográficas", + "GlobalDefaultLanguageDescription": "Idioma al que se ajustarán todas las etiquetas localizadas", + "GlobalDefaultLatitude": "Hemisferio latitud coordenadas por omisión", + "GlobalDefaultLatitudeDescription": "Hemisferio por omisión para entradas nuevas de latitud", + "GlobalDefaultLongitude": "Hemisferio longitud coordenadas por omisión", + "GlobalDefaultLongitudeDescription": "Hemisferio por omisión para entradas nuevas de longitud", + "GlobalDefaultPartDisplayFormat": "Formato de visualización de piezas", + "GlobalDefaultPartDisplayFormatDescription": "Define el formato en el que se muestran las piezas de la selección", + "GlobalDefaultScheduleableUserNameDisplayFormat": "Formato de visualización de nombre de usuario", + "GlobalDefaultScheduleableUserNameDisplayFormatDescription": "Define el formato en el que aparecen los usuarios programables en los cuadros de selección desplegables", + "GlobalDefaultServiceTemplateIDDescription": "Template used globally when no other more specific template is in effect", + "GlobalDefaultUnitNameDisplayFormat": "Formato de visualización de unidad", + "GlobalInventoryAdjustmentStartSeed": "Número de inicio ajuste de inventario", + "GlobalInventoryAdjustmentStartSeedDescription": "El número de inicio del ajuste de inventario debe ser mayor que los números utilizados existentes. Una vez introducido un número, no puede introducirse un número menor", + "GlobalLaborSchedUserDfltTimeSpan": "Scheduled / Labor default minutes", + "GlobalLaborSchedUserDfltTimeSpanDescription": "Scheduled Users/Labor default time span for new records (minutes). 0 = off", + "GlobalMainGridAutoRefresh": "Auto-refresh main grids", + "GlobalMainGridAutoRefreshDescription": "Refresh main grid lists automatically every 5 minutes", + "GlobalMaxFileSizeMB": "Maximum embedded file size", + "GlobalMaxFileSizeMBDescription": "Largest single file size in megabytes that can be stored embedded in the database", + "GlobalNotifySMTPAccount": "Acceso SMTP", + "GlobalNotifySMTPAccountDescription": "Cuenta de acceso al servidor de correo SMTP", + "GlobalNotifySMTPFrom": "Dirección Responder a / Desde SMTP", + "GlobalNotifySMTPFromDescription": "Cuenta de e-mail de remitente (dirección de respuesta) usada al enviar notificaciones", + "GlobalNotifySMTPHost": "Servidor SMTP", + "GlobalNotifySMTPHostDescription": "Servidor de correo de Internet (SMTP) utilizado para enviar mensajes de notificación", + "GlobalNotifySMTPPassword": "Contraseña SMTP", + "GlobalNotifySMTPPasswordDescription": "Contraseña para la cuenta de acceso SMTP", + "GlobalPropertyCategoryDisplayStyle": "Estilo de visualización", + "GlobalPurchaseOrderStartSeed": "Número de inicio órdenes de compra", + "GlobalPurchaseOrderStartSeedDescription": "El número de inicio de las órdenes de compra debe ser mayor que los números utilizados existentes. Una vez introducido un número, no puede introducirse un número menor.", + "GlobalQuoteNumberStartSeed": "Número de inicio presupuestos", + "GlobalQuoteNumberStartSeedDescription": "El número de inicio de los presupuestos debe ser mayor que los números utilizados existentes. Una vez introducido un número, no puede introducirse un número menor.", + "GlobalRentalStartSeed": "Número inicio alquiler", + "GlobalRentalStartSeedDescription": "El número de inicio de alquiler debe ser mayor que los números utilizados existentes. Una vez introducido un número, no puede introducirse un número menor.", + "GlobalSchedUserNonTodayStartTime": "Scheduled default time", + "GlobalSchedUserNonTodayStartTimeDescription": "Scheduled user default time for new records when choosing start date other than today", + "GlobalSignatureFooter": "Signature footer", + "GlobalSignatureFooterDescription": "Text displayed as footer below signature box", + "GlobalSignatureHeader": "Signature header", + "GlobalSignatureHeaderDescription": "Text displayed as header above signature box", + "GlobalSignatureTitle": "Signature title", + "GlobalSignatureTitleDescription": "Text displayed as title above signature area", + "GlobalSMTPEncryption": "SMTP Encryption", + "GlobalSMTPEncryptionDescription": "Encryption method to use with SMTP server. Valid values are 'TLS', 'SSL' or empty for no encryption.", + "GlobalSMTPRetry": "SMTP Retry deliveries", + "GlobalSMTPRetryDescription": "Don't remove SMTP / SMS notifications if unable to connect to SMTP server; retry them again on next notification processing until delivered", + "GlobalSpellCheckDescription": "Si se ajusta a verdadero, todos los campos de texto se compararán con la lista interna de ortografía del idioma seleccionado. Seleccionando Verdadero, aumenta el tiempo necesario para guardar un registro.", + "GlobalTaxPartPurchaseID": "Impuesto por omisión para compra de piezas", + "GlobalTaxPartPurchaseIDDescription": "Impuesto sobre venta aplicado por omisión a las piezas en las órdenes de compra", + "GlobalTaxPartSaleID": "Impuesto por omisión sobre venta de piezas", + "GlobalTaxPartSaleIDDescription": "Impuesto sobre venta aplicado por omisión a las piezas de los pedidos", + "GlobalTaxRateSaleID": "Impuesto por omisión sobre venta de servicio", + "GlobalTaxRateSaleIDDescription": "Impuesto sobre la venta aplicado por omisión a los servicios de los pedidos", + "GlobalTravelDfltTimeSpan": "Travel default minutes", + "GlobalTravelDfltTimeSpanDescription": "Travel default time span for new records (minutes). 0 = off", + "GlobalUnitNameDisplayFormatsDescription": "Determina el formato en el que se muestran las unidades en los cuadros de selección desplegables de los pedidos de servicio, presupuestos y mantenimientos preventivos", + "GlobalUseInventory": "Usar inventario", + "GlobalUseInventoryDescription": "Si se ajusta a falso, el acceso queda limitado a la entrada de piezas y a la selección de las utilizadas en los pedidos de servicio. Si se ajusta a verdadero, se permite el acceso a todas las funciones del inventario.", + "GlobalUseNotification": "Usar notificaciones", + "GlobalUseNotificationDescription": "Si se ajusta a verdadero, se activa el sistema de notificaciones. Si se ajusta a falso, todo el procesamiento de las notificaciones se desactiva.", + "GlobalUseRegions": "Usar regiones", + "GlobalUseRegionsDescription": "Si se ajusta a verdadero, los usuarios asignados a una región no podrán ver la información relativa a los asignados a otra región distinta", + "GlobalWorkorderCloseByAge": "Caducidad del pedido (minutos)", + "GlobalWorkorderCloseByAgeDescription": "Minutos tras los que debe cerrarse un pedido después de abrirse. Cuando se crea un pedido, este intervalo se añade a la fecha / hora actual para configurar automáticamente el cierre en una fecha determinada. Ajustar a cero si no se utiliza.", + "GlobalWorkorderClosedStatus": "Workorder closed status", + "GlobalWorkorderClosedStatusDescription": "If a status is selected here, a work order will be set to this status automatically when closed by a user in AyaNova or AyaNovaWBI", + "GlobalWorkorderNumberStartSeed": "Número de inicio pedidos de servicio", + "GlobalWorkorderNumberStartSeedDescription": "El número de inicio del pedido de servicio debe ser mayor que los números utilizados existentes. Una vez introducido un número, no puede introducirse un número menor.", + "GlobalWorkorderSummaryTemplate": "Plantilla resumen elemento pedido", + "GlobalWorkorderSummaryTemplateDescription": "Define la información de un elemento de pedido de servicio que aparece en la pantalla Programación", + "GridFilterName": "Filter name", + "HeadOfficeAccountNumber": "Número de cuenta", + "HeadOfficeContact": "Contact", + "HeadOfficeContactNotes": "Other contacts", + "HeadOfficeCustom0": "Campo personalizado 0", + "HeadOfficeCustom1": "Campo personalizado 1", + "HeadOfficeCustom2": "Campo personalizado 2", + "HeadOfficeCustom3": "Campo personalizado 3", + "HeadOfficeCustom4": "Campo personalizado 4", + "HeadOfficeCustom5": "Campo personalizado 5", + "HeadOfficeCustom6": "Campo personalizado 6", + "HeadOfficeCustom7": "Campo personalizado 7", + "HeadOfficeCustom8": "Campo personalizado 8", + "HeadOfficeCustom9": "Campo personalizado 9", + "HeadOfficeEmail": "Email", + "HeadOfficeList": "Sedes", + "HeadOfficeName": "Nombre de la sede", + "HeadOfficeNotes": "Notas", + "HeadOfficePhone1": "Business", + "HeadOfficePhone2": "Fax", + "HeadOfficePhone3": "Home", + "HeadOfficePhone4": "Mobile", + "HeadOfficePhone5": "Pager", + "KeyNotFound": "No se ha encontrado ninguna clave en el portapapeles", + "KeyNotValid": "La clave no ha podido validarse", + "KeySaved": "La clave se ha guardado; reinicie ahora AyaNova en todos los ordenadores", + "LoanItemCurrentWorkorderItemLoan": "ID préstamo elemento pedido actual", + "LoanItemCustom0": "Campo personalizado 0", + "LoanItemCustom1": "Campo personalizado 1", + "LoanItemCustom2": "Campo personalizado 2", + "LoanItemCustom3": "Campo personalizado 3", + "LoanItemCustom4": "Campo personalizado 4", + "LoanItemCustom5": "Campo personalizado 5", + "LoanItemCustom6": "Campo personalizado 6", + "LoanItemCustom7": "Campo personalizado 7", + "LoanItemCustom8": "Campo personalizado 8", + "LoanItemCustom9": "Campo personalizado 9", + "LoanItemList": "Elementos préstamo", + "LoanItemName": "Nombre", + "LoanItemNotes": "Notas", + "LoanItemRateDay": "Day rate", + "LoanItemRateHalfDay": "Half day rate", + "LoanItemRateHour": "Hour rate", + "LoanItemRateMonth": "Month rate", + "LoanItemRateNone": "-", + "LoanItemRateWeek": "Week rate", + "LoanItemRateYear": "Year rate", + "LoanItemSerial": "Número de serie", + "LocaleCustomizeText": "Customize text", + "LocaleExport": "Exportar localización en archivo", + "LocaleImport": "Importar localización desde archivo", + "LocaleList": "Colección texto localizado", + "LocaleLocaleFile": "Archivo de localización transportable AyaNova (*.xml)", + "LocaleUIDestLocale": "Nuevo nombre localización", + "LocaleUISourceLocale": "Localización origen", + "LocaleWarnLocaleLocked": "Your user account is using the \"English\" locale text.\r\nThis locale is read only and can not be edited.\r\nPlease change your locale in your user settings to any other value than \"English\" to proceed.", + "LocalizedTextDisplayText": "Texto estándar de visualización", + "LocalizedTextDisplayTextCustom": "Texto campo personalizado", + "LocalizedTextKey": "Clave", + "LocalizedTextLocale": "Idioma", + "MemoForward": "Reenviar", + "MemoReply": "Respuesta", + "MemoEventCreated": "Memorándum - entrada", + "MemoFromID": "Desde", + "MemoList": "Memorándums", + "MemoMessage": "Mensaje", + "MemoRe": "RE:", + "MemoReplied": "Respondido", + "MemoSent": "Enviado", + "MemoSentRelative": "Enviado (relativo)", + "MemoSubject": "Asunto", + "MemoToID": "A", + "MemoViewed": "Visualizado", + "NotifyNotificationMessage": "Mensaje", + "NotifySourceOfEvent": "Origen", + "NotifyDeliveryLogDelivered": "Envío correcto", + "NotifyDeliveryLogDeliveryDate": "Enviado con fecha", + "NotifyDeliveryLogErrorMessage": "Mensaje de error", + "NotifyDeliveryLogList": "Envío de notificaciones (últimos 7 días)", + "NotifyDeliveryLogToUser": "Enviado a", + "NotifyDeliveryMessageFormatsBrief": "Formato compacto", + "NotifyDeliveryMessageFormatsFull": "Formato completo", + "NotifyDeliveryMethodsMemo": "Memorándum AyaNova", + "NotifyDeliveryMethodsPopUp": "Cuadro mensaje desplegable", + "NotifyDeliveryMethodsSMS": "Dispositivo activado SMS", + "NotifyDeliveryMethodsSMTP": "Cuenta de correo de Internet", + "NotifyDeliverySettingAddress": "Dirección", + "NotifyDeliverySettingAllDay": "Todo el día", + "NotifyDeliverySettingAnyTime": "Enviar notificaciones a cualquier hora", + "NotifyDeliverySettingDeliver": "Entrega", + "NotifyDeliverySettingDeliveryMethod": "Método de envío físico", + "NotifyDeliverySettingEndTime": "Hora de fin", + "NotifyDeliverySettingEventWindows": "Enviar notificaciones sólo a estas horas:", + "NotifyDeliverySettingList": "Métodos de envío de notificaciones", + "NotifyDeliverySettingMaxCharacters": "Máximo de caracteres", + "NotifyDeliverySettingMessageFormat": "Formato del mensaje", + "NotifyDeliverySettingName": "Nombre", + "NotifyDeliverySettingStartTime": "Hora de inicio", + "NotifySubscriptionCreated": "Suscrito", + "NotifySubscriptionEventDescription": "Evento", + "NotifySubscriptionList": "Suscripciones a notificaciones", + "NotifySubscriptionPendingSpan": "Notificar antes del evento", + "NotifySubscriptionWarningNoDeliveryMethod": "Se requiere por lo menos un método de envío de notificaciones para poder suscribirse. ¿Desea configurar ahora uno?", + "NotifySubscriptionDeliveryUIAddNew": "Añadir método de envío", + "Address": "Dirección", + "AssignedDoc": "Documento", + "AyaFile": "Embedded file", + "Client": "Cliente", + "ClientGroup": "Grupo de clientes", + "ClientNote": "Nota del cliente", + "ClientNoteType": "Tipo de nota de cliente", + "ClientRequestPart": "Pieza solicitada", + "ClientRequestTech": "Usuario programable solicitado", + "ClientRequestWorkorder": "Pedido solicitado", + "ClientRequestWorkorderItem": "Elemento del pedido solicitado", + "ClientServiceRequest": "Solicitud servicio cliente", + "ClientServiceRequestItem": "Elemento solicitud de servicio del cliente", + "Contact": "Contacto", + "ContactPhone": "Teléfono del contacto", + "ContactTitle": "Tratamiento contacto", + "Contract": "Contrato", + "ContractPart": "Pieza del contrato", + "ContractRate": "Tarifa de contrato", + "DispatchZone": "Zona de reparto", + "Global": "Global", + "GlobalWikiPage": "Global Wiki page", + "GridFilter": "GridFilter", + "HeadOffice": "Sede", + "LoanItem": "Elemento en préstamo", + "Locale": "Localización", + "LocalizedText": "Texto localizado", + "Maintenance": "Mantenimiento interno AyaNova", + "Memo": "Memorándum", + "NameFetcher": "Objeto recopilador de nombres", + "Notification": "Notificación", + "NotifySubscription": "Suscripción a notificaciones", + "NotifySubscriptionDelivery": "Método de envío de notificaciones", + "Part": "Pieza", + "PartAssembly": "Montaje de la pieza", + "PartByWarehouseInventory": "Pieza de inventario de almacén", + "PartCategory": "Categoría de la pieza", + "PartInventoryAdjustment": "Ajuste inventario de piezas", + "PartInventoryAdjustmentItem": "Elemento ajuste inventario de piezas", + "PartSerial": "Pieza registrada", + "PartWarehouse": "Almacén de la pieza", + "Priority": "Prioridad", + "Project": "Proyecto", + "PurchaseOrder": "Orden de compra", + "PurchaseOrderItem": "Elemento de la orden de compra", + "PurchaseOrderReceipt": "Recibo orden de compra", + "PurchaseOrderReceiptItem": "Elemento de recibo de orden de compra", + "Rate": "Tarifa", + "RateUnitChargeDescription": "Descripción unidad de tarifa", + "Region": "Región", + "Rental": "Alquiler", + "RentalUnit": "Unidad de alquiler", + "Report": "Informe", + "ScheduleableUserGroup": "Grupo de usuarios programables", + "ScheduleableUserGroupUser": "Usuario de grupo de usuarios programables", + "ScheduleForm": "Formulario programación", + "ScheduleMarker": "Marca de programación", + "SecurityGroup": "Grupo de seguridad", + "ServiceBank": "Pagos por adelantado", + "Task": "Tarea", + "TaskGroup": "Grupo de tareas", + "TaskGroupTask": "Tarea de grupo de tareas", + "TaxCode": "Código fiscal", + "Unit": "Unidad", + "UnitMeterReading": "Lectura de medición de unidad", + "UnitModel": "Modelo de unidad", + "UnitModelCategory": "Categoría modelo unidad", + "UnitOfMeasure": "Unidad de medida", + "UnitServiceType": "Tipo de servicio de unidad", + "User": "Usuario", + "UserCertification": "Certificado de usuario", + "UserCertificationAssigned": "CertificadoUsuarioAsignado", + "UserRight": "Objeto Derecho Usuario", + "UserSkill": "Habilidad de usuario", + "UserSkillAssigned": "Habilidad usuario asignada", + "Vendor": "Proveedor", + "WikiPage": "Wiki page", + "Workorder": "Pedido", + "WorkorderClose": "Close work order", + "WorkorderCategory": "Categoría pedido", + "WorkorderItem": "Elemento del pedido", + "WorkorderItemLabor": "Mano de obra elemento pedido", + "WorkorderItemLoan": "Préstamo elemento de pedido", + "WorkorderItemMiscExpense": "Gastos varios elemento pedido", + "WorkorderItemPart": "Pieza elemento de pedido", + "WorkorderItemPartRequest": "Solicitud pieza elemento de pedido", + "WorkorderItemScheduledUser": "Usuario programado elemento de pedido", + "WorkorderItemTask": "Tarea elemento de pedido", + "WorkorderItemTravel": "Elemento de pedido desplazamiento", + "WorkorderItemType": "Tipo de elemento de pedido", + "WorkorderItemUnit": "Workorder item unit", + "WorkorderPreventiveMaintenance": "Mantenimiento preventivo", + "WorkorderPreventiveMaintenanceTemplate": "Preventive maintenance template", + "WorkorderQuote": "Presupuesto", + "WorkorderQuoteTemplate": "Quote template", + "WorkorderService": "Pedido", + "WorkorderServiceTemplate": "Service template", + "WorkorderStatus": "Estado del pedido", + "ObjectCustomFieldCustomGrid": "Campos personalizados", + "ObjectCustomFieldDisplayName": "Mostrar como", + "ObjectCustomFieldFieldName": "Nombre de campo", + "ObjectCustomFieldFieldType": "Tipo de datos del campo", + "ObjectCustomFieldObjectName": "Nombre de objeto", + "ObjectCustomFieldVisible": "Visible", + "OutsideServiceList": "Lista de servicios externos", + "PartMustTrackSerial": "El seguimiento de los números de serie no puede ajustarse a \"falso\" ya que esta pieza ya tiene un historial registrado con números de serie", + "PartTrackSerialHasInventory": "Track serial numbers can not be turned on as this part still has items in inventory", + "PartAlert": "Texto de la alerta", + "PartAlternativeWholesalerID": "Mayorista alternativo", + "PartAlternativeWholesalerNumber": "Número de mayorista alternativo", + "PartCustom0": "Campo personalizado 0", + "PartCustom1": "Campo personalizado 1", + "PartCustom2": "Campo personalizado 2", + "PartCustom3": "Campo personalizado 3", + "PartCustom4": "Campo personalizado 4", + "PartCustom5": "Campo personalizado 5", + "PartCustom6": "Campo personalizado 6", + "PartCustom7": "Campo personalizado 7", + "PartCustom8": "Campo personalizado 8", + "PartCustom9": "Campo personalizado 9", + "PartList": "Piezas", + "PartManufacturerID": "Fabricante", + "PartManufacturerNumber": "Número de fabricante", + "PartName": "Nombre de la pieza", + "PartNotes": "Notas", + "PartPartNumber": "Número de pieza", + "PartRetail": "Minorista", + "PartTrackSerialNumber": "Seguimiento número de serie", + "PartUPC": "UPC", + "PartWholesalerID": "Mayorista", + "PartWholesalerNumber": "Número de mayorista", + "PartAssemblyDescription": "Descripción", + "PartAssemblyList": "Montajes de piezas", + "PartAssemblyName": "Nombre de montaje de la pieza", + "PartByWarehouseInventoryList": "Inventario de piezas", + "PartByWarehouseInventoryMinStockLevel": "Nivel de reposición", + "PartByWarehouseInventoryQtyOnOrderCommitted": "Cantidad en pedido comprometida", + "PartByWarehouseInventoryQuantityOnHand": "Disponible", + "PartByWarehouseInventoryQuantityOnOrder": "En pedido", + "PartByWarehouseInventoryReorderQuantity": "Cantidad de reposición", + "PartCategoryList": "Categorías de pieza", + "PartCategoryName": "Nombre de categoría de la pieza", + "PartDisplayFormatsAssemblyNumberName": "Montaje - número - nombre", + "PartDisplayFormatsCategoryNumberName": "Categoría - número - nombre", + "PartDisplayFormatsManufacturerName": "Fabricante - nombre", + "PartDisplayFormatsManufacturerNumber": "Fabricante - número", + "PartDisplayFormatsName": "Sólo nombre", + "PartDisplayFormatsNameCategoryNumberManufacturer": "Name - category - number - manufacturer", + "PartDisplayFormatsNameNumber": "Nombre - número", + "PartDisplayFormatsNameNumberManufacturer": "Name - number - manufacturer", + "PartDisplayFormatsNameUPC": "Nombre - UPC", + "PartDisplayFormatsNumber": "Sólo número", + "PartDisplayFormatsNumberName": "Número - nombre", + "PartDisplayFormatsNumberNameManufacturer": "Número - nombre - fabricante", + "PartDisplayFormatsUPC": "Sólo UPC", + "PartInventoryAdjustmentAdjustmentNumber": "Número", + "PartInventoryAdjustmentDateAdjusted": "Fecha de ajuste", + "PartInventoryAdjustmentPartInventoryAdjustmentID": "ID ajuste", + "PartInventoryAdjustmentReasonForAdjustment": "Motivo", + "PartInventoryAdjustmentItemNegativeQuantityInvalid": "No hay suficientes piezas de este tipo en el almacén para retirarlas del inventario", + "PartInventoryAdjustmentItemPartNotUnique": "La misma combinación pieza / almacén sólo puede utilizarse una vez en un mismo ajuste", + "PartInventoryAdjustmentItemZeroQuantityInvalid": "Se necesita una cantidad", + "PartInventoryAdjustmentItemQuantityAdjustment": "Ajuste cantidad", + "PartRestockRequiredByVendorList": "Reposición de piezas requerida proveedor", + "PartSerialAdjustmentID": "Ajuste", + "PartSerialAvailable": "Disponible", + "PartSerialDateConsumed": "Consumido", + "PartSerialDateReceived": "Recibido", + "PartSerialSerialNumberNotUnique": "Número de serie ya introducido para esta pieza", + "PartSerialWarehouseID": "Almacén de la pieza", + "PartWarehouseDescription": "Descripción", + "PartWarehouseList": "Almacenes de piezas", + "PartWarehouseName": "Nombre de almacén de la pieza", + "PriorityColor": "Color", + "PriorityList": "Prioridades", + "PriorityName": "Nombre prioridad", + "ProjectAccountNumber": "Número de cuenta", + "ProjectCustom0": "Campo personalizado 0", + "ProjectCustom1": "Campo personalizado 1", + "ProjectCustom2": "Campo personalizado 2", + "ProjectCustom3": "Campo personalizado 3", + "ProjectCustom4": "Campo personalizado 4", + "ProjectCustom5": "Campo personalizado 5", + "ProjectCustom6": "Campo personalizado 6", + "ProjectCustom7": "Campo personalizado 7", + "ProjectCustom8": "Campo personalizado 8", + "ProjectCustom9": "Campo personalizado 9", + "ProjectDateCompleted": "Completado con fecha", + "ProjectDateStarted": "Fecha de inicio", + "ProjectList": "Proyectos", + "ProjectName": "Nombre del proyecto", + "ProjectNotes": "Notas", + "ProjectProjectOverseerID": "Supervisor del proyecto", + "PurchaseOrderActualReceiveDate": "Fecha acordada", + "PurchaseOrderCustom0": "Campo personalizado 0", + "PurchaseOrderCustom1": "Campo personalizado 1", + "PurchaseOrderCustom2": "Campo personalizado 2", + "PurchaseOrderCustom3": "Campo personalizado 3", + "PurchaseOrderCustom4": "Campo personalizado 4", + "PurchaseOrderCustom5": "Campo personalizado 5", + "PurchaseOrderCustom6": "Campo personalizado 6", + "PurchaseOrderCustom7": "Campo personalizado 7", + "PurchaseOrderCustom8": "Campo personalizado 8", + "PurchaseOrderCustom9": "Campo personalizado 9", + "PurchaseOrderDropShipToClientID": "Envío directo al cliente", + "PurchaseOrderLocked": "Orden de compra bloqueada debido a su estado", + "PurchaseOrderExpectedReceiveDate": "Previsión", + "PurchaseOrderNotes": "Notas", + "PurchaseOrderOrderedDate": "Fecha del pedido", + "PurchaseOrderPONumber": "Número de pedido", + "PurchaseOrderStatusClosedFullReceived": "Cerrado - todo recibido", + "PurchaseOrderStatusClosedNoneReceived": "Cerrado - nada recibido", + "PurchaseOrderStatusClosedPartialReceived": "Cerrado - recibido en parte", + "PurchaseOrderStatusOpenNotYetOrdered": "Abierto - aún no pedido", + "PurchaseOrderStatusOpenOrdered": "Abierto - en pedido", + "PurchaseOrderStatusOpenPartialReceived": "Abierto - recibido en parte", + "PurchaseOrderReferenceNumber": "Número de referencia", + "PurchaseOrderShowPartsAllVendors": "Select from any vendor's part", + "PurchaseOrderStatus": "Estado de la orden de compra", + "PurchaseOrderUICopyToPurchaseOrder": "Copiar en orden de compra", + "PurchaseOrderUINoPartsForVendorWarning": "El proveedor seleccionado no tiene piezas definidas en AyaNova. No podrá introducir elementos de órdenes de compra para este proveedor.", + "PurchaseOrderUIOrderedWarning": "¿Seguro que desea fijar el estado de esta orden de compra a Pedido?", + "PurchaseOrderUIRestockList": "Lista de reposición", + "PurchaseOrderVendorMemo": "Memorándum proveedor", + "PurchaseOrderItemClosed": "Cerrado", + "PurchaseOrderItemLineTotal": "Total línea", + "PurchaseOrderItemNetTotal": "Total neto", + "PurchaseOrderItemPartName": "Nombre de la pieza", + "PurchaseOrderItemPartNumber": "Número de pieza", + "PurchaseOrderItemPartRequestedByID": "Solicitado por", + "PurchaseOrderItemPurchaseOrderCost": "Coste orden de compra", + "PurchaseOrderItemQuantityOrdered": "Cantidad solicitada", + "PurchaseOrderItemQuantityReceived": "Cantidad recibida", + "PurchaseOrderItemUIOrderedFrom": "Pedido a", + "PurchaseOrderItemUISaveWarning": "¿Seguro que desea guardar? Una vez guardado, el registro quedará bloqueado y no podrá editarse.", + "PurchaseOrderItemWorkorderNumber": "Núm. pedido", + "PurchaseOrderReceiptItems": "Elemento recibo orden de compra", + "PurchaseOrderReceiptPartRequestNotFound": "Solicitud de pieza no encontrada", + "PurchaseOrderReceiptReceivedDate": "Recibido con fecha", + "PurchaseOrderReceiptText1": "Text1", + "PurchaseOrderReceiptText2": "Text2", + "PurchaseOrderReceiptItemPONumber": "Orden de compra", + "PurchaseOrderReceiptItemPurchaseOrderItemID": "Elemento de la orden de compra", + "PurchaseOrderReceiptItemPurchaseOrderReceiptID": "Recibo orden de compra", + "PurchaseOrderReceiptItemQuantityReceived": "Cantidad recibida", + "PurchaseOrderReceiptItemQuantityReceivedErrorInvalid": "La entrada debe ser igual o inferior a la cantidad restante de la orden de compra y no negativa", + "PurchaseOrderReceiptItemReceiptCost": "Coste real", + "PurchaseOrderReceiptItemReferenceNumber": "Referencia", + "PurchaseOrderReceiptItemWarehouseID": "Almacén de la pieza", + "PurchaseOrderReceiptItemWorkorderNumber": "Núm. pedido", + "RateAccountNumber": "Número de cuenta", + "RateCharge": "Cargo minorista", + "RateClientGroupID": "Grupo de clientes", + "RateContractRate": "Tarifa de contrato", + "RateDescription": "Descripción", + "RateList": "Tarifas", + "RateName": "Nombre tarifa", + "RateRateType": "Tipo de tarifa", + "RateRateTypeRental": "Alquiler", + "RateRateTypeService": "Servicio", + "RateRateTypeTravel": "Desplazamiento", + "RateRateUnitChargeDescriptionID": "Descripción cargo unidad", + "RateUnitChargeDescriptionList": "Unidades de tarifa", + "RateUnitChargeDescriptionName": "Nombre descripción unidad de tarifa", + "RateUnitChargeDescriptionNamePlural": "Plural name", + "RegionAttachQuote": "Attach quote report", + "RegionAttachWorkorder": "Attach workorder report", + "RegionClientNotifyMessage": "Message to send to client", + "RegionCSRAccepted": "CSR accepted", + "RegionCSRRejected": "CSR rejected", + "RegionDefaultPurchaseOrderTemplate": "Plantilla por omisión de orden de compra", + "RegionDefaultQuoteTemplate": "Plantilla por omisión de presupuesto", + "RegionDefaultWorkorderTemplate": "Plantilla por omisión de pedido", + "RegionFollowUpDays": "Days after work order closed", + "RegionList": "Regiones", + "RegionName": "Nombre de región", + "RegionNewWO": "New WO", + "RegionQuoteStatusChanged": "Quote status changed", + "RegionReplyToEmailAddress": "Reply to email address", + "RegionWBIUrl": "AyaNova WBI url address", + "RegionWOClosedEmailed": "WO Closed", + "RegionWOFollowUp": "WO follow up", + "RegionWorkorderClosedStatus": "Estado del pedido cerrado", + "RegionWOStatusChanged": "WO status changed", + "ReportActive": "Activo", + "ReportDesignReport": "Diseño", + "ReportImportDuplicate": "El informe seleccionado no puede importarse: Ya existe un informe con el mismo valor de identificación interna en la base de datos", + "ReportExport": "Exportar a...", + "ReportExportHTML": "Archivo HTML (*.html)", + "ReportExportPDF": "Archivo Acrobat (*.pdf)", + "ReportExportRTF": "Archivo de texto con estilos (*.rtf)", + "ReportExportTIFF": "Archivo TIFF (*.tif)", + "ReportExportTXT": "Archivo de texto (*.txt)", + "ReportExportXLS": "Archivo Excel (*.xls)", + "ReportExportLayout": "Exportar plantilla", + "ReportExportLayoutFile": "Archivo de informe transportable AyaNova (*.ayr)", + "ReportImportLayout": "Importar plantilla", + "ReportList": "Plantillas de informes", + "ReportMaster": "Informe maestro", + "ReportMasterWarning": "Informe maestro - sólo lectura", + "ReportName": "Nombre", + "ReportNewDetailedReport": "Nueva plantilla informe detallado", + "ReportNewSummaryReport": "Nueva plantilla informe resumen", + "ReportReportKey": "Clave", + "ReportReportSize": "Tamaño (bytes)", + "ReportSaveAsDialogTitle": "Guardar plantilla de informe como...", + "ReportSecurityGroupID": "Restrict to security group", + "ReportEditorControls": "Caja de herramientas", + "ReportEditorExplorer": "Explorador", + "ReportEditorFields": "Campos", + "ReportEditorProperties": "Propiedades", + "ScheduleableUserGroupDescription": "Descripción", + "ScheduleableUserGroupList": "Grupos de usuarios programables", + "ScheduleableUserGroupName": "Nombre grupo de usuarios programables", + "ScheduleableUserGroupScheduleableUsers": "Usuarios programables", + "ScheduleableUserGroupUserScheduleableUserGroupID": "ID grupo de usuarios programables", + "ScheduleableUserGroupUserScheduleableUserID": "Usuario programable", + "ScheduleableUserNameDisplayFormatsEmployeeNumberFirstLast": "NúmeroEmpleado - Nombre Apellido", + "ScheduleableUserNameDisplayFormatsEmployeeNumberInitials": "Número - iniciales empleado", + "ScheduleableUserNameDisplayFormatsFirstLast": "Nombre Apellido", + "ScheduleableUserNameDisplayFormatsFirstLastRegion": "First Last - Region", + "ScheduleableUserNameDisplayFormatsInitials": "Iniciales", + "ScheduleableUserNameDisplayFormatsLastFirst": "Apellido, Nombre", + "ScheduleableUserNameDisplayFormatsLastFirstRegion": "Last, First - Region", + "ScheduleableUserNameDisplayFormatsRegionFirstLast": "Region - First Last", + "ScheduleableUserNameDisplayFormatsRegionLastFirst": "Region - Last, First", + "ScheduledList": "Lista programada", + "ScheduleMarkerARGB": "ARGB", + "ScheduleMarkerColor": "Color", + "ScheduleMarkerCompleted": "Completed", + "ScheduleMarkerEventCreated": "Marca de programación - Recién creada", + "ScheduleMarkerEventPendingAlert": "Marca de programación - Evento inminente", + "ScheduleMarkerFollowUp": "Follow up", + "ScheduleMarkerList": "Schedule markers", + "ScheduleMarkerName": "Nombre", + "ScheduleMarkerNotes": "Notas", + "ScheduleMarkerRecurrence": "Recurrencia", + "ScheduleMarkerScheduleMarkerSourceType": "Para", + "ScheduleMarkerSourceID": "Origen", + "ScheduleMarkerStartDate": "Inicio", + "ScheduleMarkerStopDate": "Fin", + "SearchResultDescription": "Descripción", + "SearchResultExtract": "Extracto", + "SearchResultRank": "Rango", + "SearchResultSource": "Origen", + "SecurityGroupList": "Grupos de seguridad", + "SecurityGroupName": "Nombre grupo de seguridad", + "SecurityLevelTypesNoAccess": "No autorizado", + "SecurityLevelTypesReadOnly": "Sólo lectura", + "SecurityLevelTypesReadWrite": "Leer / escribir", + "SecurityLevelTypesReadWriteDelete": "Leer / escribir / borrar", + "ServiceBankAppliesToRootObjectID": "Se aplica al objeto raíz", + "ServiceBankAppliesToRootObjectType": "Se aplica al tipo de objeto raíz", + "ServiceBankCreated": "Introducido", + "ServiceBankCreator": "Usuario", + "ServiceBankCurrency": "Divisa", + "ServiceBankCurrencyBalance": "Balance divisas", + "ServiceBankDescription": "Descripción", + "ServiceBankEffectiveDate": "Fecha efectiva", + "ServiceBankEventCurrencyBalanceZero": "Pago por adelantado - divisas agotadas", + "ServiceBankEventHoursBalanceZero": "Pago por adelantado - horas agotadas", + "ServiceBankEventIncidentsBalanceZero": "Pago por adelantado - incidentes agotados", + "ServiceBankHours": "Horas", + "ServiceBankHoursBalance": "Balance de horas", + "ServiceBankID": "ID", + "ServiceBankIncidents": "Incidentes", + "ServiceBankIncidentsBalance": "Balance de incidentes", + "ServiceBankList": "Lista de servicios restados del pago por adelantado", + "ServiceBankSourceRootObjectID": "ID origen", + "ServiceBankSourceRootObjectType": "Origen", + "StopWords1": "a acuerdo adelante ademas además adrede ahi ahí ahora al alli allí alrededor antano antaño ante antes apenas aproximadamente aquel aquél aquella aquélla aquellas aquéllas aquello aquellos aquéllos aqui aquí arribaabajo asi así aun aún aunque b bajo", + "StopWords2": "bastante bien breve c casi cerca claro como cómo con conmigo contigo contra cual cuál cuales cuáles cuando cuándo cuanta cuánta cuantas cuántas cuanto cuánto cuantos cuántos d de debajo del delante demasiado dentro deprisa desde despacio despues después", + "StopWords3": "detras detrás dia día dias días donde dónde dos durante e el él ella ellas ellos en encima enfrente enseguida entre es esa ésa esas ésas ese ése eso esos ésos esta está ésta estado estados estan están estar estas éstas este éste esto estos éstos ex", + "StopWords4": "excepto f final fue fuera fueron g general gran h ha habia había habla hablan hace hacia han hasta hay horas hoy i incluso informo informó j junto k l la lado las le lejos lo los luego m mal mas más mayor me medio mejor menos menudo mi mí mia mía mias", + "StopWords5": "mías mientras mio mío mios míos mis mismo mucho muy n nada nadie ninguna no nos nosotras nosotros nuestra nuestras nuestro nuestros nueva nuevo nunca o os otra otros p pais paìs para parte pasado peor pero poco por porque pronto proximo próximo puede q", + "StopWords6": "qeu que qué quien quién quienes quiénes quiza quizá quizas quizás raras repente s salvo se sé segun según ser sera será si sí sido siempre sin sobre solamente solo sólo son soyos su supuesto sus suya suyas suyo t tal tambien también tampoco tarde te", + "StopWords7": "temprano ti tiene todavia todavía todo todos tras tu tú tus tuya tuyas tuyo tuyos u un una unas uno unos usted ustedes v veces vez vosotras vosotros vuestra vuestras vuestro vuestros w x y ya yo z", + "TaskList": "Tareas", + "TaskName": "Nombre de tarea", + "TaskGroupDescription": "Descripción", + "TaskGroupList": "Grupos de tareas", + "TaskGroupName": "Nombre grupo de tareas", + "TaskGroupTaskTaskGroupID": "Grupo de tareas", + "TaxCodeDefault": "Error: este código fiscal se usa por omisión en los ajustes globales y no puede borrarse ni desactivarse", + "TaxCodeList": "Códigos fiscales", + "TaxCodeName": "Nombre código fiscal", + "TaxCodeNotes": "Notas", + "TaxCodeTaxA": "Impuesto \"A\"", + "TaxCodeTaxAExempt": "Exención impuesto \"A\"", + "TaxCodeTaxAValue": "Valor impuesto A", + "TaxCodeTaxB": "Impuesto \"B\"", + "TaxCodeTaxBExempt": "Exención impuesto \"B\"", + "TaxCodeTaxBValue": "Valor impuesto B", + "TaxCodeTaxOnTax": "Impuesto sobre impuesto", + "Add": "Añadir", + "Cancel": "Cancelar", + "Close": "Salir", + "Closed": "Cambiar estado cerrado", + "CurrentDateAndTime": "Fecha y hora actual", + "CustomFieldDesign": "Diseño campo personalizado", + "Delete": "Borrar", + "Duplicate": "Duplicado", + "Edit": "Edit", + "ExternalTools": "External tools", + "LocalizedTextDesign": "Diseño texto localizado", + "OK": "Aceptar", + "Open": "Abrir", + "Ordered": "Pedido", + "Paste": "Pegar", + "RecordHistory": "Historial del registro", + "Save": "Guardar", + "SaveClose": "Guardar y salir", + "SaveNew": "Guardar y nuevo", + "Search": "Buscar", + "ServiceHistory": "Historial del servicio", + "Administration": "Administración", + "AdministrationGlobalSettings": "Ajustes globales", + "Home": "Home", + "Inventory": "Inventario", + "InventoryPartInventoryAdjustments": "Ajustes", + "InventoryPartInventoryAdjustmentsDetailed": "Items", + "InventoryPurchaseOrderReceipts": "Recibos orden de compra", + "InventoryPurchaseOrderReceiptsReceive": "Recibir inventario", + "InventoryPurchaseOrderReceiptsDetailed": "Items", + "InventoryPurchaseOrders": "Órdenes de compra", + "InventoryPurchaseOrdersDetailed": "Items", + "Logout": "Desconectar", + "PreventiveMaintenance": "Mantenimiento preventivo", + "Quotes": "Presupuestos", + "Schedule": "Programación", + "Service": "Servicio", + "ServicePreventiveMaintenance": "MP", + "ServiceQuotes": "Presupuestos", + "UnitModels": "Modelos de unidad", + "UserMail": "Correo", + "UserPreferences": "Preferencias del usuario", + "UserPunchClock": "Reloj para fichar del usuario", + "VendorsSubContractors": "Subcontratistas", + "VendorsWholesalers": "Mayoristas", + "GridFilterDialogAddConditionButtonText": "&Añadir una condición", + "GridFilterDialogAndRadioText": "Condiciones \"y\"", + "GridFilterDialogCancelButtonText": "&Cancelar", + "GridFilterDialogDeleteButtonText": "Borrar condición", + "GridFilterDialogOkButtonNoFiltersText": "&Sin filtros", + "GridFilterDialogOkButtonText": "&Aceptar", + "GridFilterDialogOrRadioText": "Condiciones \"o\"", + "GridRowFilterDialogBlanksItem": "(En blanco)", + "GridRowFilterDialogDBNullItem": "(DBNull)", + "GridRowFilterDialogEmptyTextItem": "(Texto vacío)", + "GridRowFilterDialogOperandHeaderCaption": "Operando", + "GridRowFilterDialogOperatorHeaderCaption": "Operador", + "GridRowFilterDialogTitlePrefix": "Introducir criterios de filtrado para", + "GridRowFilterDropDownAllItem": "(Todo)", + "GridRowFilterDropDownBlanksItem": "(En blanco)", + "GridRowFilterDropDownCustomItem": "(Personalizado)", + "GridRowFilterDropDownEquals": "Igual a", + "GridRowFilterDropDownGreaterThan": "Mayor que", + "GridRowFilterDropDownGreaterThanOrEqualTo": "Mayor o igual a", + "GridRowFilterDropDownLessThan": "Menor que", + "GridRowFilterDropDownLessThanOrEqualTo": "Menor o igual a", + "GridRowFilterDropDownLike": "Como", + "GridRowFilterDropDownMatch": "Coincide con expresión regular", + "GridRowFilterDropDownNonBlanksItem": "(NoEnBlanco)", + "GridRowFilterDropDownNotEquals": "No es igual a", + "GridRowFilterRegexError": "Error analizando expresión regular {0}. Introduzca una expresión regular válida.", + "GridRowFilterRegexErrorCaption": "Expresión regular no válida", + "HelpAboutAyaNova": "Acerca de AyaNova", + "HelpCheckForUpdates": "Comprobar actualizaciones", + "HelpContents": "&Contenido...", + "HelpLicense": "License", + "HelpPurchaseLicenses": "Licencias de compra", + "HelpTechSupport": "Asistencia técnica", + "AllDay": "All day", + "AnyUser": "All users", + "ComboMoreRecordsPrompt": "< Más... >", + "CopyOfText": "Copia de", + "DateRange14DayWindow": "Ventana - 14 días", + "DateRangeFuture": "Future", + "DateRangeInTheLastSixMonths": "In the last 6 months", + "DateRangeInTheLastThreeMonths": "In the last 3 months", + "DateRangeInTheLastYear": "In the last year", + "DateRangeLastMonth": "Mes - Anterior", + "DateRangeLastWeek": "Week - Previous", + "DateRangeLastYear": "Year - Last", + "DateRangeNextMonth": "Mes - Siguiente", + "DateRangeNextWeek": "Semana - Siguiente", + "DateRangePast": "Past", + "DateRangeThisMonth": "Mes - Actual", + "DateRangeThisWeek": "Semana - Actual", + "DateRangeThisYear": "Year - Current", + "DateRangeToday": "Hoy", + "DateRangeTomorrow": "Mañana", + "DateRangeYesterday": "Yesterday", + "DayAny": "Cualquier día de la semana", + "DayFriday": "Viernes", + "DayMonday": "Lunes", + "DaySaturday": "Sábado", + "DaySunday": "Domingo", + "DayThursday": "Jueves", + "DayTuesday": "Martes", + "DayWednesday": "Miércoles", + "DayOfWeek": "Día de la semana", + "DeletePrompt": "¿Seguro que desea borrar este registro para siempre?", + "DeleteWorkorderPrompt": "Are you sure you want to delete this Workorder permanently?", + "East": "Este", + "FilterAvailableTo": "Available to", + "Filtered": "Filtrado", + "FilterNone": "No filter", + "FilterUnsaved": "Unsaved filter", + "Find": "Buscar", + "FindAndReplace": "Buscar y reemplazar", + "FirstRows100": "First 100 rows", + "FirstRows1000": "First 1000 rows", + "FirstRows500": "First 500 rows", + "FirstRowsAll": "All rows", + "LastServiceDate": "Fecha del último servicio cerrado", + "LastWorkorder": "Último pedido de servicio cerrado", + "LineTotal": "Total línea", + "NetValue": "Neto", + "North": "Norte", + "Object": "Objeto", + "Replace": "Reemplazar", + "SavePrompt": "¿Desea guardar los cambios?", + "SelectItem": "Seleccionar", + "SelectPurchaseOrdersToReceive": "Seleccionar órdenes de compra por recibir...", + "SelectVendor": "Seleccionar proveedor...", + "SetLoginPassword": "Definir acceso y contraseña", + "ShowAll": "Show all...", + "South": "Sur", + "SubTotal": "Subtotal", + "TimeSpanDays": "días", + "TimeSpanFutureRelative": "a partir de ahora", + "TimeSpanHours": "horas", + "TimeSpanMinutes": "minutos", + "TimeSpanMonths": "meses", + "TimeSpanPastRelative": "atrás", + "TimeSpanSeconds": "segundos", + "TimeSpanWeeks": "semanas", + "TimeSpanWithinTheHour": "Within the hour", + "TimeSpanYears": "años", + "Total": "Total", + "UnsaveableDueToBrokenRulesPrompt": "Este registro no puede guardarse porque contiene una o más reglas que no se cumplen. \r\n\r\n¿Continuar con la operación de guardar?", + "West": "Oeste", + "MenuGo": "Navegación", + "MenuHelp": "Ayuda", + "MenuMRU": "Recent...", + "MenuSubGrids": "Subcuadrícula", + "RecordHistoryCreated": "Fecha de creación del registro", + "RecordHistoryCreator": "Creador de este registro", + "RecordHistoryModified": "Fecha de modificación del registro", + "RecordHistoryModifier": "Última modificación de este registro", + "AddRemoveButtons": "Añadir y eliminar botones....", + "ClientsToolBar": "Barra de herramientas Clientes", + "Customize": "Personalizar....", + "CustomizeDialog_AlwaysShowFullMenus": "Mostrar siempre menús completos", + "CustomizeDialog_CloseNoAmp": "Cerrar", + "CustomizeDialog_FloatingToolbarFadeDelay": "Atenuación barra flotante", + "CustomizeDialog_KeyboardBeginAmp": "Teclado....", + "CustomizeDialog_LargeIconsOnMenus": "Iconos grandes en los menús", + "CustomizeDialog_LargeIconsOnToolbars": "Iconos grandes en barras", + "CustomizeDialog_Milliseconds": "Milisegundos", + "CustomizeDialog_New": "Nuevo....", + "CustomizeDialog_Other": "Otro", + "CustomizeDialog_PersonalizedMenusAndToolbars": "Menús y barras personalizados", + "CustomizeDialog_Rename": "Cambiar nombre....", + "CustomizeDialog_ResetAmp": "Reinicializar....", + "CustomizeDialog_ResetMyUsageData": "Reinicializar mis datos de uso", + "CustomizeDialog_SelectedCommand": "Comando seleccionado:", + "CustomizeDialog_ShowFullMenusAfterAShortDelay": "Mostrar menús completos tras pequeña demora", + "CustomizeDialog_ShowScreenTipsOnToolbars": "Notas de ayuda en barras de herramientas", + "CustomizeDialog_ShowShortcutKeysInScreenTips": "Mostrar atajos en las notas de ayuda", + "CustomizeDlgKbdCat": "Categorías:", + "CustomizeDlgKbdCmd": "Comandos", + "MainMenuBar": "Barra de menús principal", + "MainToolBar": "Barra de herramientas principal", + "New": "Nuevo....", + "PageSetup": "Page setup", + "Print": "Imprimir", + "PrintPreview": "Print preview", + "Refresh": "Refrescar...", + "ResetToolbar": "Reinicializar barra de herramientas", + "ScheduleActiveWorkorderItem": "Elemento del pedido activo:", + "ScheduleAddToActiveWorkorderItem": "Añadir selección al elemento de pedido activo", + "ScheduleDayView": "Sólo un día", + "ScheduleEditScheduleableUserGroup": "Editar grupos de usuarios programables", + "ScheduleEditScheduleMarker": "Editar marca de programación seleccionada", + "ScheduleEditWorkorder": "Editar pedido seleccionado", + "ScheduleMergeUsers": "Mostrar junto / separado", + "ScheduleMonthView": "Mes", + "ScheduleNewScheduleMarker": "Nueva marca de programación", + "ScheduleNewWorkorder": "Nuevo pedido de servicio", + "SchedulePrintWorkorders": "Print selected work orders", + "ScheduleSelectScheduleableUserGroup": "Seleccionar usuarios programables", + "ScheduleShowClosed": "Display Open / Closed (and open) work orders", + "ScheduleTimeLineView": "Display single day view as time line / regular", + "ScheduleToday": "Hoy", + "ScheduleWeekView": "Semana de 7 días", + "ScheduleWorkWeekView": "Semana de 5 días laborables", + "ScheduleToolBar": "Barra de herramientas Programación", + "SecurityGroupFormSetAll": "Fijar todos los niveles de seguridad al nivel seleccionado", + "WorkorderFormSetAllPartsUsedInService": "Ajustar todas las piezas a en uso", + "UIGridLayoutDescription": "Descripción disposición cuadrícula", + "UIGridLayoutGridKey": "Clave cuadrícula", + "UIGridLayoutLayoutContent": "Datos disposición cuadrícula", + "UIGridLayoutLayoutSize": "Tamaño datos disposición cuadrícula", + "UIGridLayoutObjectName": "Objeto disposición de cuadrícula interfaz", + "UnitBoughtHere": "Adquirido aquí", + "UnitCustom0": "Campo personalizado 0", + "UnitCustom1": "Campo personalizado 1", + "UnitCustom2": "Campo personalizado 2", + "UnitCustom3": "Campo personalizado 3", + "UnitCustom4": "Campo personalizado 4", + "UnitCustom5": "Campo personalizado 5", + "UnitCustom6": "Campo personalizado 6", + "UnitCustom7": "Campo personalizado 7", + "UnitCustom8": "Campo personalizado 8", + "UnitCustom9": "Campo personalizado 9", + "UnitDescription": "Descripción", + "UnitLastMeter": "Última lectura de medición", + "UnitLifeSpan": "Duración", + "UnitList": "Unidades", + "UnitMetered": "Unidad medida", + "UnitNotes": "Notas", + "UnitOverrideLength": "Duración garantía", + "UnitOverrideLifeTime": "Anular garantía de por vida", + "UnitOverrideWarranty": "Garantía específica", + "UnitOverrideWarrantyExpiryDate": "Fecha caducidad garantía específica", + "UnitOverrideWarrantyTerms": "Condiciones garantía específica", + "UnitParentUnitID": "Unidad matriz de esta", + "UnitPurchasedDate": "Fecha de compra", + "UnitPurchaseFromID": "Adquirido a", + "UnitReceipt": "Recibo número", + "UnitReplacedByUnitID": "Reemplazado por unidad", + "UnitSerial": "Número de serie", + "UnitText1": "Text1", + "UnitText2": "Text2", + "UnitText3": "Text3", + "UnitText4": "Text4", + "UnitUINotWarrantiedDisplay": "La unidad no está garantizada", + "UnitUIWarrantiedDisplay": "Unidad con garantía hasta {0}\r\n\r\nCondiciones de la garantía: =-=-=-=-=-=-=-=- {1}", + "UnitUIWarrantyExpiredDisplay": "La garantía de la unidad expiró el {0}", + "UnitUnitHasOwnAddress": "La unidad tiene dirección propia", + "UnitWorkorderLastServicedID": "Último pedido servido", + "UnitMeterReadingDescription": "Descripción de la medición", + "UnitMeterReadingList": "Lista lectura de medición de unidad", + "UnitMeterReadingMeter": "Lectura de medición de unidad", + "UnitMeterReadingMeterDate": "Fecha lectura de medición", + "UnitMeterReadingWorkorderItemID": "Lectura medición en pedido", + "UnitModelCustom0": "Campo personalizado 0", + "UnitModelCustom1": "Campo personalizado 1", + "UnitModelCustom2": "Campo personalizado 2", + "UnitModelCustom3": "Campo personalizado 3", + "UnitModelCustom4": "Campo personalizado 4", + "UnitModelCustom5": "Campo personalizado 5", + "UnitModelCustom6": "Campo personalizado 6", + "UnitModelCustom7": "Campo personalizado 7", + "UnitModelCustom8": "Campo personalizado 8", + "UnitModelCustom9": "Campo personalizado 9", + "UnitModelDiscontinued": "Descontinuado", + "UnitModelDiscontinuedDate": "Descontinuado con fecha", + "UnitModelIntroducedDate": "Introducido con fecha", + "UnitModelLifeTimeWarranty": "Garantía de por vida", + "UnitModelList": "Modelos de unidad", + "UnitModelModelNumber": "Número de modelo", + "UnitModelName": "Nombre modelo unidad", + "UnitModelNotes": "Notas", + "UnitModelUPC": "UPC", + "UnitModelVendorID": "Unit model vendor", + "UnitModelWarrantyLength": "Duración de la garantía", + "UnitModelWarrantyTerms": "Condiciones de la garantía", + "UnitModelCategoryDescription": "Descripción", + "UnitModelCategoryList": "Categorías modelo unidad", + "UnitModelCategoryName": "Nombre categoría modelo unidad", + "UnitNameDisplayFormatsModelModelNumberSerial": "Model name, model number, serial number", + "UnitNameDisplayFormatsModelNumberModelSerial": "Model number, model name, serial number", + "UnitNameDisplayFormatsModelSerial": "Número de modelo, número de serie", + "UnitNameDisplayFormatsSerialDescription": "Serial number, description", + "UnitNameDisplayFormatsSerialModel": "Número de serie, número de modelo", + "UnitNameDisplayFormatsSerialModelVendor": "Número de serie, número de modelo, proveedor", + "UnitNameDisplayFormatsSerialOnly": "Número de serie", + "UnitNameDisplayFormatsVendorModelModelNumberSerial": "Vendor, model name, model number, serial number", + "UnitNameDisplayFormatsVendorModelSerial": "Proveedor, número de modelo, número de serie", + "UnitNameDisplayFormatsVendorSerial": "Proveedor - Número de serie", + "UnitNameDisplayFormatsVendorSerialDescription": "Vendor, serial number, description", + "UnitOfMeasureList": "Unidades de medida", + "UnitOfMeasureName": "Nombre unidad de medida", + "UnitServiceTypeDescription": "Descripción", + "UnitServiceTypeList": "Tipos de servicio de unidad", + "UnitServiceTypeName": "Nombre", + "UserCustom0": "Campo personalizado 0", + "UserCustom1": "Campo personalizado 1", + "UserCustom2": "Campo personalizado 2", + "UserCustom3": "Campo personalizado 3", + "UserCustom4": "Campo personalizado 4", + "UserCustom5": "Campo personalizado 5", + "UserCustom6": "Campo personalizado 6", + "UserCustom7": "Campo personalizado 7", + "UserCustom8": "Campo personalizado 8", + "UserCustom9": "Campo personalizado 9", + "UserDefaultWarehouseID": "Almacén por omisión", + "UserEmailAddress": "Dirección e-mail del usuario", + "UserEmployeeNumber": "Número de empleado", + "UserErrorNotSelectable": "El usuario seleccionado no está activo o no es programable", + "UserEventQuickNotification": "Quick Notification", + "UserFirstName": "Nombre de pila", + "UserInitials": "Iniciales", + "UserLastName": "Apellido", + "UserList": "Usuarios", + "UserLogin": "Nombre de acceso", + "UserMemberOfGroup": "Grupo de seguridad", + "UserMustBeActive": "This user must be active as it has open schedule items", + "UserMustBeScheduleable": "This user must be a Scheduleable User type to preserve data history", + "UserNotes": "Notas", + "UserPageAddress": "Dirección buscapersonas", + "UserPageMaxText": "Texto máx. buscapersonas", + "UserPassword": "Contraseña", + "UserPhone1": "Teléfono1", + "UserPhone2": "Teléfono 2", + "UserScheduleBackColor": "Color fondo programación", + "UserStatus": "Status", + "UserSubContractor": "Es subcontratista", + "UserTimeZoneOffset": "Override timezone", + "UserUIClearAllLayoutCustomization": "Borrar elementos personalizados del formulario", + "UserUserCertifications": "Certificados", + "UserUserSkills": "Habilidades", + "UserUserType": "Tipo de usuario", + "UserVendorID": "Proveedor subcontratista", + "UserCertificationDescription": "Descripción", + "UserCertificationList": "Certificados del usuario", + "UserCertificationName": "Nombre certificado de usuario", + "UserCertificationAssignedValidStartDate": "Fecha inicio válida", + "UserCertificationAssignedValidStopDate": "Fecha fin válida", + "UserRightList": "Derechos miembro", + "UserRightRight": "Objeto interno", + "UserRightSecurityLevel": "Nivel de seguridad", + "UserSkillDescription": "Descripción", + "UserSkillList": "Habilidades del usuario", + "UserSkillName": "Nombre habilidad de usuario", + "UserTypesAdministrator": "Usuario administrador", + "UserTypesClient": "Usuario cliente", + "UserTypesHeadOffice": "Usuario cliente de sede", + "UserTypesNonSchedulable": "Usuario no programable", + "UserTypesSchedulable": "Usuario programable", + "UserTypesUtilityNotification": "Cuenta servidor de notificación", + "VendorAccountNumber": "Número de cuenta", + "VendorContact": "Contact", + "VendorContactNotes": "Other contacts", + "VendorCustom0": "Campo personalizado 0", + "VendorCustom1": "Campo personalizado 1", + "VendorCustom2": "Campo personalizado 2", + "VendorCustom3": "Campo personalizado 3", + "VendorCustom4": "Campo personalizado 4", + "VendorCustom5": "Campo personalizado 5", + "VendorCustom6": "Campo personalizado 6", + "VendorCustom7": "Campo personalizado 7", + "VendorCustom8": "Campo personalizado 8", + "VendorCustom9": "Campo personalizado 9", + "VendorEmail": "Email", + "VendorList": "Proveedores", + "VendorName": "Nombre del proveedor", + "VendorNotes": "Notas", + "VendorPhone1": "Business", + "VendorPhone2": "Fax", + "VendorPhone3": "Home", + "VendorPhone4": "Mobile", + "VendorPhone5": "Pager", + "VendorVendorType": "Tipo de proveedor", + "VendorVendorTypeManufacturer": "Fabricante", + "VendorVendorTypeShipper": "Transportista", + "VendorVendorTypeSubContractor": "Subcontratista", + "VendorVendorTypeThirdPartyRepair": "Reparación terceros", + "VendorVendorTypeWholesaler": "Mayorista", + "WikiPageInternalOnly": "Internal users only", + "WikiPageTitle": "Title", + "WorkorderClosed": "Cerrado", + "WorkorderConvertScheduledUserToLabor": "Convertir usuario programado en mano de obra", + "WorkorderCopyWorkorderItem": "Copiar elemento de pedido seleccionado en un pedido existente para este cliente", + "WorkorderMoveWorkorderItem": "Mover elemento de pedido a otro pedido", + "WorkorderCustomerContactName": "Contacto", + "WorkorderCustomerReferenceNumber": "Referencia cliente", + "WorkorderDeleted": "Borrado", + "WorkorderClosedIsPermanent": "No se puede asignar el estado abierto a un pedido ya cerrado", + "WorkorderDeleteLastWorkorderItem": "Un pedido siempre debe incluir al menos un elemento de pedido", + "WorkorderDirtyOrBrokenRules": "Esta operación no puede completarse; el pedido no se ha guardado o no cumple algunas reglas", + "WorkorderLoanItemsNotReturned": "Esta operación no puede completarse. Aún no se han devuelto uno o más elementos prestados", + "WorkorderNotCloseableDueToErrors": "Este pedido no puede cerrarse porque no se cumplen una o varias reglas", + "WorkorderNotCompleteableDueToErrors": "Este pedido no puede marcarse como completado debido a que no se cumplen una o varias reglas", + "WorkorderPartRequestsOnOrder": "Esta operación no puede completarse: una o más solicitudes de piezas están pedidas y no se han recibido", + "WorkorderPartRequestsUnOrdered": "This operation can not be completed - One or more unordered part requests need to be removed first", + "WorkorderSourceInvalidType": "El tipo de pedido de origen no es válido", + "WorkorderEventCloseByDatePassed": "Pedido - sobrepasada la fecha de cierre", + "WorkorderEventQuoteUpdated": "Quote - created / updated", + "WorkorderEventStatus": "Pedido - \"Estado\" cambiado", + "WorkorderFormLayoutID": "ID disposición formulario", + "WorkorderFromPMID": "M.P. origen", + "WorkorderFromQuoteID": "Presupuesto origen", + "WorkorderGenerateUnit": "Generate unit from selected part", + "WorkorderInternalReferenceNumber": "Referencia interna", + "WorkorderListAll": "List all work orders", + "WorkorderOnsite": "En el lugar", + "WorkorderServiceCompleted": "Servicio completado", + "WorkorderSign": "Sign", + "WorkorderSummary": "Resumen", + "WorkorderTemplate": "Plantilla", + "WorkorderTemplateDescription": "Template description", + "WorkorderTemplateFreshPrice": "Use current Part prices on generated order", + "WorkorderTemplateID": "ID plantilla", + "WorkorderWarningClosedChanged": "Advertencia: Si continúa, el pedido se cerrará para siempre. \r\n\r\n¿Está seguro?", + "WorkorderWarningNotAllPartsUsed": "Advertencia: Una o más piezas de este pedido no tienen el estado EN USO.\r\n\r\nSi continúa, todas las piezas se ajustarán automáticamente al estado EN USO.\r\n\r\n¿Está seguro?", + "WorkorderWarningServiceCompletedChanged": "Advertencia: Si continúa, se cerrará este formulario y cambiará el estado completado del servicio del pedido. \r\n \r\n¿Está seguro?", + "WorkorderWorkorderItems": "Elementos del pedido", + "WorkorderCategoryDescription": "Descripción", + "WorkorderCategoryList": "Categorías de pedido", + "WorkorderCategoryName": "Nombre categoría del pedido", + "WorkorderDetailsList": "Detalles del pedido", + "WorkorderItemCustom0": "Campo personalizado 0", + "WorkorderItemCustom1": "Campo personalizado 1", + "WorkorderItemCustom2": "Campo personalizado 2", + "WorkorderItemCustom3": "Campo personalizado 3", + "WorkorderItemCustom4": "Campo personalizado 4", + "WorkorderItemCustom5": "Campo personalizado 5", + "WorkorderItemCustom6": "Campo personalizado 6", + "WorkorderItemCustom7": "Campo personalizado 7", + "WorkorderItemCustom8": "Campo personalizado 8", + "WorkorderItemCustom9": "Campo personalizado 9", + "WorkorderItemCustomFields": "Campos personalizados", + "WorkorderItemEventNotServiced": "Elemento de pedido - no servido con suficiente rapidez", + "WorkorderItemExpenses": "Gastos", + "WorkorderItemLabors": "Mano de obra", + "WorkorderItemList": "Elementos", + "WorkorderItemLoans": "Préstamos", + "WorkorderItemOutsideService": "Servicio externo", + "WorkorderItemPartRequests": "Solicitudes de piezas", + "WorkorderItemParts": "Piezas", + "WorkorderItemPriorityID": "Prioridad", + "WorkorderItemRequestDate": "Fecha de solicitud", + "WorkorderItemScheduledUsers": "Usuarios programados", + "WorkorderItemSummary": "Resumen elemento", + "WorkorderItemTaskListID": "Lista de tareas", + "WorkorderItemTasks": "Tareas", + "WorkorderItemTechNotes": "Notas del servicio", + "WorkorderItemTravels": "Desplazamiento", + "WorkorderItemTypeID": "Tipo de elemento de pedido", + "WorkorderItemWarrantyService": "Servicio de garantía", + "WorkorderItemWorkorderStatusID": "Estado elemento pedido", + "WorkorderItemLaborLaborBanked": "Restado del pago por adelantado", + "WorkorderItemLaborLaborRateCharge": "Tarifa", + "WorkorderItemLaborList": "Elementos mano de obra", + "WorkorderItemLaborNoChargeQuantity": "Cantidad sin cargo", + "WorkorderItemLaborServiceDetails": "Detalles del servicio", + "WorkorderItemLaborServiceRateID": "Tarifa del servicio", + "WorkorderItemLaborServiceRateQuantity": "Unidades tarifa de servicio", + "WorkorderItemLaborServiceStartDate": "Fecha y hora inicio de servicio", + "WorkorderItemLaborServiceStopDate": "Fecha y hora fin de servicio", + "WorkorderItemLaborTaxCodeID": "Código fiscal", + "WorkorderItemLaborTaxRateSaleID": "Impuesto sobre la venta", + "WorkorderItemLaborUIBankWarning": "¿Seguro que desea restar este registro de los pagos por adelantado? (Una vez restado, quedará bloqueado y ya no podrá editarse)", + "WorkorderItemLaborUIReBankWarning": "Este elemento ya se ha restado de los pagos por adelantado", + "WorkorderItemLaborUserID": "Usuario", + "WorkorderItemLoanCharges": "Costes", + "WorkorderItemLoanDueDate": "Devolución prevista", + "WorkorderItemLoanList": "Elementos préstamo", + "WorkorderItemLoanLoanItem": "Elemento préstamo", + "WorkorderItemLoanLoanItemID": "Elemento en préstamo", + "WorkorderItemLoanLoanTaxA": "Impuesto A", + "WorkorderItemLoanLoanTaxAExempt": "Exención impuesto A", + "WorkorderItemLoanLoanTaxB": "Impuesto B", + "WorkorderItemLoanLoanTaxBExempt": "Exención impuesto B", + "WorkorderItemLoanLoanTaxOnTax": "Impuesto sobre impuesto", + "WorkorderItemLoanLoanTaxRateSale": "Impuesto", + "WorkorderItemLoanNotes": "Notas", + "WorkorderItemLoanOutDate": "En préstamo", + "WorkorderItemLoanQuantity": "Rate quantity", + "WorkorderItemLoanRate": "Rate", + "WorkorderItemLoanRateAmount": "Rate amount", + "WorkorderItemLoanReturnDate": "Devuelto", + "WorkorderItemLoanTaxCodeID": "Impuesto sobre la venta", + "WorkorderItemMiscExpenseChargeAmount": "Cantidad por cobrar", + "WorkorderItemMiscExpenseChargeTaxCodeID": "Código fiscal aplicable", + "WorkorderItemMiscExpenseChargeToClient": "¿Cobrar al cliente?", + "WorkorderItemMiscExpenseDescription": "Descripción", + "WorkorderItemMiscExpenseTaxA": "Valor impuesto A gastos varios", + "WorkorderItemMiscExpenseTaxAExempt": "Exención impuesto A gastos varios", + "WorkorderItemMiscExpenseTaxB": "Valor impuesto B gastos varios", + "WorkorderItemMiscExpenseTaxBExempt": "Exención impuesto B gastos varios", + "WorkorderItemMiscExpenseTaxOnTax": "Impuesto sobre impuesto gastos varios", + "WorkorderItemMiscExpenseTaxRateSale": "Tarifa impuesto gastos varios", + "WorkorderItemMiscExpenseList": "Elementos gastos varios", + "WorkorderItemMiscExpenseName": "Resumen gastos varios", + "WorkorderItemMiscExpenseReimburseUser": "¿Reembolsar al usuario?", + "WorkorderItemMiscExpenseTaxPaid": "Impuesto pagado", + "WorkorderItemMiscExpenseTotalCost": "Coste total", + "WorkorderItemMiscExpenseUser": "Usuario", + "WorkorderItemMiscExpenseUserID": "Usuario", + "WorkorderItemOutsideServiceDateETA": "Llegada prevista", + "WorkorderItemOutsideServiceDateReturned": "Devuelto fecha", + "WorkorderItemOutsideServiceDateSent": "Fecha de envío", + "WorkorderItemOutsideServiceEventUnitBackFromService": "Servicio externo elemento de pedido - unidad devuelta", + "WorkorderItemOutsideServiceEventUnitNotBackFromServiceByETA": "Servicio externo elemento de pedido - unidad fuera de plazo", + "WorkorderItemOutsideServiceNotes": "Notas", + "WorkorderItemOutsideServiceReceivedBack": "Recibido de vuelta", + "WorkorderItemOutsideServiceRepairCost": "Coste reparación", + "WorkorderItemOutsideServiceRepairPrice": "Precio reparación", + "WorkorderItemOutsideServiceRMANumber": "Número RMA", + "WorkorderItemOutsideServiceSenderUserID": "Enviado por", + "WorkorderItemOutsideServiceShippingCost": "Coste de envío", + "WorkorderItemOutsideServiceShippingPrice": "Precio de envío", + "WorkorderItemOutsideServiceTrackingNumber": "Núm. seguimiento", + "WorkorderItemOutsideServiceVendorSentToID": "Enviado a", + "WorkorderItemOutsideServiceVendorSentViaID": "Enviado por", + "WorkorderItemPartDescription": "Descripción", + "WorkorderItemPartDiscount": "Descuento", + "WorkorderItemPartDiscountType": "Tipo de descuento", + "WorkorderItemPartHasAffectedInventory": "Ha afectado al inventario", + "WorkorderItemPartList": "Elementos piezas", + "WorkorderItemPartPartID": "Pieza", + "WorkorderItemPartPartSerialID": "Número de serie", + "WorkorderItemPartPartWarehouseID": "Almacén", + "WorkorderItemPartPrice": "Precio", + "WorkorderItemPartQuantity": "Cantidad", + "WorkorderItemPartQuantityReserved": "Cantidad preseleccionada", + "WorkorderItemPartTaxPartSaleID": "Impuesto sobre la venta", + "WorkorderItemPartUIQuantityReservedPM": "Cantidad solicitada", + "WorkorderItemPartUIQuantityReservedQuote": "Cantidad presupuestada", + "WorkorderItemPartUsed": "En uso en el servicio", + "WorkorderItemPartWarningInsufficientStock": "Existencias insuficientes ({0:N}). ¿Desea solicitar {1:N}?", + "WorkorderItemPartWarningPartNotFound": "La pieza no está en la lista de piezas", + "WorkorderItemPartRequestNotDeleteableOnOrder": "No se puede borrar una solicitud de pieza de un elemento de pedido cuando las piezas están pedidas. Una vez recibidas las piezas, puede borrarse.", + "WorkorderItemPartRequestEventPartsReceived": "Solicitud pieza elemento de pedido - Piezas recibidas", + "WorkorderItemPartRequestList": "Solicitudes de piezas", + "WorkorderItemPartRequestOnOrder": "En pedido", + "WorkorderItemPartRequestPartID": "Pieza", + "WorkorderItemPartRequestPartWarehouseID": "Almacén", + "WorkorderItemPartRequestQuantity": "Cantidad", + "WorkorderItemPartRequestReceived": "Recibido", + "WorkorderItemScheduledUserRecordIncomplete": "Nada por programar", + "WorkorderItemScheduledUserEstimatedQuantity": "Cantidad estimada", + "WorkorderItemScheduledUserEventCreatedUpdated": "Usuario programado elemento de pedido - (creado / actualizado)", + "WorkorderItemScheduledUserEventPendingAlert": "Usuario programado elemento de pedido - Evento inminente", + "WorkorderItemScheduledUserList": "Elementos usuarios programados", + "WorkorderItemScheduledUserServiceRateID": "Tarifa sugerida", + "WorkorderItemScheduledUserStartDate": "Fecha y hora de inicio", + "WorkorderItemScheduledUserStartDateRelative": "Inicio (relativo)", + "WorkorderItemScheduledUserStopDate": "Fecha y hora de fin", + "WorkorderItemScheduledUserUserID": "Usuario", + "WorkorderItemScheduledUserWarnOutOfRegion": "Warning: User is not in client's region - won't see this item", + "WorkorderItemTaskCompletionTypeComplete": "Completado", + "WorkorderItemTaskCompletionTypeIncomplete": "Tareas pendientes", + "WorkorderItemTaskCompletionTypeNotApplicable": "N/D", + "WorkorderItemTaskObject": "Tarea elemento de pedido", + "WorkorderItemTaskTaskID": "Tarea", + "WorkorderItemTaskWorkorderItemTaskCompletionType": "Estado", + "WorkorderItemTravelDistance": "Distancia", + "WorkorderItemTravelList": "Elementos desplazamiento", + "WorkorderItemTravelNoChargeQuantity": "Cantidad sin cargo", + "WorkorderItemTravelNotes": "Notas", + "WorkorderItemTravelServiceRateID": "Tarifa desplazamiento", + "WorkorderItemTravelTaxCodeID": "Código fiscal", + "WorkorderItemTravelTaxRateSaleID": "Impuesto sobre la venta", + "WorkorderItemTravelDetails": "Detalles del desplazamiento", + "WorkorderItemTravelRateCharge": "Cargo tarifa desplazamiento", + "WorkorderItemTravelRateID": "Tarifa desplazamiento", + "WorkorderItemTravelRateQuantity": "Cantidad", + "WorkorderItemTravelStartDate": "Fecha de inicio", + "WorkorderItemTravelStopDate": "Fecha de fin", + "WorkorderItemTravelUserID": "Usuario", + "WorkorderItemTypeDescription": "Descripción", + "WorkorderItemTypeList": "Tipos de elementos de pedido", + "WorkorderItemTypeName": "Nombre tipo de elemento de pedido", + "WorkorderPreventiveMaintenanceDayOfTheWeek": "Día de la semana deseado", + "WorkorderPreventiveMaintenanceGenerateServiceWorkorder": "Generar pedido de servicio manualmente", + "WorkorderPreventiveMaintenanceGenerateSpan": "Frecuencia de servicio", + "WorkorderPreventiveMaintenanceGenerateSpanUnit": "Generar", + "WorkorderPreventiveMaintenanceList": "Mantenimiento preventivo", + "WorkorderPreventiveMaintenanceNextServiceDate": "Fecha del próximo servicio", + "WorkorderPreventiveMaintenanceStopGeneratingDate": "Fecha fin de generación", + "WorkorderPreventiveMaintenanceThresholdSpan": "Antelación del pedido", + "WorkorderPreventiveMaintenanceThresholdSpanUnit": "Umbral", + "WorkorderPreventiveMaintenanceByUnitList": "Mantenimiento preventivo por unidad", + "WorkorderQuoteDateApproved": "Aprobado", + "WorkorderQuoteDateSubmitted": "Presentado", + "WorkorderQuoteGenerateServiceWorkorder": "Generar pedido de servicio a partir de este presupuesto", + "WorkorderQuoteIntroduction": "Texto de presentación", + "WorkorderQuoteList": "Presupuestos", + "WorkorderQuotePreparedByID": "Preparado por el usuario", + "WorkorderQuoteQuoteNumber": "Número de presupuesto", + "WorkorderQuoteQuoteRequestDate": "Solicitado", + "WorkorderQuoteQuoteStatusType": "Estado", + "WorkorderQuoteServiceWorkorderID": "Pedido del servicio", + "WorkorderQuoteValidUntilDate": "Válido hasta", + "WorkorderQuoteStatusTypesAwarded": "Concedido", + "WorkorderQuoteStatusTypesInProgress": "En proceso", + "WorkorderQuoteStatusTypesNew": "New", + "WorkorderQuoteStatusTypesNotAwarded": "No concedido", + "WorkorderQuoteStatusTypesNotAwarded2": "Beyond economical repair", + "WorkorderQuoteStatusTypesSubmitted": "Presentado, esperando", + "WorkorderServiceAge": "Age", + "WorkorderServiceClientRequestID": "Referencia solicitud del cliente", + "WorkorderServiceCloseByDate": "Cerrar en fecha", + "WorkorderServiceInvoiceNumber": "Número de factura", + "WorkorderServiceList": "Pedidos de servicio", + "WorkorderServiceQuoteWorkorderID": "Presupuesto", + "WorkorderServiceServiceDate": "Fecha de servicio", + "WorkorderServiceServiceDateRelative": "Fecha de servicio (relativa)", + "WorkorderServiceServiceNumber": "Número de servicio", + "WorkorderServiceWorkorderPreventiveMaintenanceWorkorderID": "Mantenimiento preventivo", + "WorkorderStatusARGB": "Color ARGB", + "WorkorderStatusBold": "Negrita", + "WorkorderStatusCompletedStatus": "El estado es \"Completado\"", + "WorkorderStatusList": "Estados del pedido", + "WorkorderStatusName": "Nombre estado del pedido", + "WorkorderStatusUnderlined": "Subrayado", + "WorkorderSummaryTemplate": "Plantilla resumen elemento pedido", + "WorkorderSummaryWorkorderItem": "Información mostrada elemento de pedido" +} \ No newline at end of file diff --git a/server/AyaNova/resource/fr.json b/server/AyaNova/resource/fr.json new file mode 100644 index 00000000..9e916cc8 --- /dev/null +++ b/server/AyaNova/resource/fr.json @@ -0,0 +1,1415 @@ +{ + "AddressType": "Type d'adresse", + "AddressTypePhysical": "Adresse physique", + "AddressTypePhysicalDescription": "Il s'agit de l'adresse physique du bâtiment, où sont livrés les éléments", + "AddressTypePostal": "Adresse postale", + "AddressTypePostalDescription": "Il s'agit de l'adresse destinataire du courrier postal", + "AddressCity": "Ville", + "AddressCopyToPhysical": "Copy to physical address", + "AddressCopyToPostal": "Copy to postal address", + "AddressCountry": "Pays", + "AddressCountryCode": "Indicatif national", + "AddressDeliveryAddress": "Rue", + "AddressFullAddress": "Adresse complète", + "AddressLatitude": "Latitude", + "AddressLongitude": "Longitude", + "AddressMapQuestURL": "Lien Web vers carte MapQuest", + "AddressPostal": "Code postal", + "AddressPostalCity": "Ville (courrier)", + "AddressPostalCountry": "Pays (courrier)", + "AddressPostalDeliveryAddress": "Adresse (courrier)", + "AddressPostalPostal": "Code postal (courrier)", + "AddressPostalStateProv": "Région / Province (courrier)", + "AddressStateProv": "Région ou province", + "AdminEraseDatabase": "Effacer toute la base de données AyaNova", + "AdminEraseDatabaseLastWarning": "Attention : ceci est votre dernière chance d'annuler l'effacement définitif de toutes les données. Souhaitez-vous réellement effacer toutes les données ?", + "AdminEraseDatabaseWarning": "Attention : vous êtes sur le point d'effacer définitivement toutes les données d'AyaNova. Êtes-vous sûr ?", + "AdminPasteLicense": "Coller la clé de licence", + "AssignedDocDescription": "Description", + "AssignedDocList": "Documents", + "AssignedDocURL": "Lien de document", + "AyaFileFileTooLarge": "File size exceeds limit of {0}", + "AyaFileFileSize": "Size", + "AyaFileFileSizeStored": "Size stored", + "AyaFileFileType": "Type", + "AyaFileList": "Files in database", + "AyaFileSource": "Source", + "ClientAccountNumber": "Numéro de compte", + "ClientBillHeadOffice": "Siège social de facturation", + "ClientContact": "Contact", + "ClientContactNotes": "Other contacts", + "ClientCustom0": "Champ personnalisé 0", + "ClientCustom1": "Champ personnalisé 1", + "ClientCustom2": "Champ personnalisé 2", + "ClientCustom3": "Champ personnalisé 3", + "ClientCustom4": "Champ personnalisé 4", + "ClientCustom5": "Champ personnalisé 5", + "ClientCustom6": "Champ personnalisé 6", + "ClientCustom7": "Champ personnalisé 7", + "ClientCustom8": "Champ personnalisé 8", + "ClientCustom9": "Champ personnalisé 9", + "ClientEmail": "Email", + "ClientEventContractExpire": "Client - contrat en cours d'expiration", + "ClientList": "Clients", + "ClientName": "Nom de client", + "ClientNotes": "Notes générales", + "ClientNotification": "Send client notifications", + "ClientPhone1": "Business", + "ClientPhone2": "Fax", + "ClientPhone3": "Home", + "ClientPhone4": "Mobile", + "ClientPhone5": "Pager", + "ClientPopUpNotes": "Notes contextuelles", + "ClientTechNotes": "Notes d'utilisateur programmable", + "ClientGroupDescription": "Description", + "ClientGroupList": "Groupes de clients", + "ClientGroupName": "Nom de groupe de clients", + "ClientNoteClientNoteTypeID": "Type de note client", + "ClientNoteList": "Notes de client", + "ClientNoteNoteDate": "Date de note", + "ClientNoteNotes": "Notes", + "ClientNoteTypeList": "Types de note client", + "ClientNoteTypeName": "Nom de type de note de client", + "ClientRequestPartClientServiceRequestItemID": "Élément de demande de service client", + "ClientRequestPartPrice": "Prix", + "ClientRequestPartQuantity": "Quantité", + "ClientRequestTechClientServiceRequestItemID": "Élément de demande de service client", + "ClientRequestTechScheduledStartDate": "Date de début programmé demandée", + "ClientRequestTechScheduledStopDate": "Date de fin programmée demandée", + "ClientRequestTechUserID": "Utilisateur programmable demandé", + "ClientServiceRequestAcceptToExisting": "Accept to existing work order", + "ClientServiceRequestAcceptToNew": "Accept to new work order", + "ClientServiceRequestCustomContactName": "Nom de contact", + "ClientServiceRequestCustomerReferenceNumber": "Numéro de référence", + "ClientServiceRequestDetailedServiceToBePerformed": "Détails de service à fournir", + "ClientServiceRequestDetails": "Details", + "ClientServiceRequestEventCreated": "Client service request - New", + "ClientServiceRequestEventCreatedUpdated": "Demande de service de client - nouveau / actualisé", + "ClientServiceRequestList": "Customer service requests", + "ClientServiceRequestOnsite": "Sur site", + "ClientServiceRequestParts": "Pièces", + "ClientServiceRequestPreferredTechs": "Utilisateurs programmables demandés", + "ClientServiceRequestPriority": "Priorité", + "ClientServiceRequestReject": "Reject service request", + "ClientServiceRequestRequestedBy": "Requested by", + "ClientServiceRequestStatus": "Status", + "ClientServiceRequestTitle": "Title", + "ClientServiceRequestWorkorderItems": "Éléments de service demandé", + "ClientServiceRequestItemServiceToBePerformed": "Résumé de service à fournir", + "ClientServiceRequestItemUnitID": "Unité", + "ClientServiceRequestPriorityASAP": "ASAP", + "ClientServiceRequestPriorityEmergency": "Emergency", + "ClientServiceRequestPriorityNotUrgent": "Not urgent", + "ClientServiceRequestStatusAccepted": "Accepted", + "ClientServiceRequestStatusClosed": "Closed", + "ClientServiceRequestStatusDeclined": "Declined", + "ClientServiceRequestStatusOpen": "Open", + "CommonActive": "Actif", + "CommonContractExpires": "Expiration du contrat", + "CommonCost": "Coût", + "CommonCreated": "Enregistrement créé", + "CommonCreator": "Enregistrement créé par", + "CommonDefaultLanguage": "Langue par défaut", + "CommonDescription": "Description", + "CommonID": "Numéro d'identification unique", + "CommonModified": "Enregistrement dernièrement modifié", + "CommonModifier": "Enregistrement dernièrement modifié par", + "CommonMore": "More...", + "CommonName": "Nom", + "CommonRootObject": "Objet racine", + "CommonRootObjectType": "Type d'objet racine", + "CommonSerialNumber": "Numéro de série", + "CommonUsesBanking": "Prépaiements", + "CommonWebAddress": "Adresse Web", + "ContactContactTitleID": "Titre", + "ContactDescription": "Description", + "ContactEmailAddress": "Adresse e-mail", + "ContactFirstName": "Prénom", + "ContactFullContact": "Contact complet", + "ContactJobTitle": "Fonction", + "ContactLastName": "Nom", + "ContactPhones": "Numéros de téléphone", + "ContactPrimaryContact": "Contact principal", + "ContactRootObjectID": "ID d'objet racine", + "ContactRootObjectType": "Type d'objet racine", + "ContactPhoneContactID": "Contact", + "ContactPhoneType": "Type de téléphone de contact", + "ContactPhoneTypeBusiness": "Activité", + "ContactPhoneTypeFax": "Fax", + "ContactPhoneTypeHome": "Domicile", + "ContactPhoneTypeMobile": "Portable", + "ContactPhoneTypePager": "Messageur", + "ContactPhoneFullPhoneRecord": "Téléphone complet", + "ContactPhoneAreaCode": "Indicatif régional", + "ContactPhoneCountryCode": "Indicatif national", + "ContactPhoneDefault": "Téléphone par défaut", + "ContactPhoneExtension": "Extension", + "ContactPhoneNumber": "Numéro de téléphone", + "ContactPhoneTypeName": "Nom de type de téléphone", + "ContactPhoneTypeObjectName": "Type", + "ContactTitleList": "Titres de contact", + "ContactTitleName": "Nom de titre", + "ContractContractRatesOnly": "Limiter aux tarifs de contrat", + "ContractCustom0": "Champ personnalisé 0", + "ContractCustom1": "Champ personnalisé 1", + "ContractCustom2": "Champ personnalisé 2", + "ContractCustom3": "Champ personnalisé 3", + "ContractCustom4": "Champ personnalisé 4", + "ContractCustom5": "Champ personnalisé 5", + "ContractCustom6": "Champ personnalisé 6", + "ContractCustom7": "Champ personnalisé 7", + "ContractCustom8": "Champ personnalisé 8", + "ContractCustom9": "Champ personnalisé 9", + "ContractDiscountParts": "Remise appliquée à toutes les pièces", + "ContractList": "Contrats", + "ContractName": "Nom de contrat", + "ContractNotes": "Notes", + "ContractRateList": "Tarifs de contrat", + "ContractRatesRateID": "Tarifs", + "CoordinateTypesDecimalDegrees": "Degrés décimaux (DDD,ddd°)", + "CoordinateTypesDegreesDecimalMinutes": "Degrés minutes (DDD° MM,mmm)", + "CoordinateTypesDegreesMinutesSeconds": "Degrés Minutes Secondes (DDD° MM' SS,sss')", + "CustomFieldKey": "Clé de champ personnalisé", + "DashboardDashboard": "Dashboard", + "DashboardNext": "Next", + "DashboardNotAssigned": "Not assigned", + "DashboardOverdue": "Overdue", + "DashboardReminders": "Reminders", + "DashboardScheduled": "Scheduled", + "DispatchZoneDescription": "Description", + "DispatchZoneList": "Zones d'expédition", + "DispatchZoneName": "Nom de zone d'expédition", + "ErrorAutoIncrementNumberTooLow": "Erreur : le nouveau numéro doit être au moins {0} afin de ne pas être confondu avec les enregistrements existants", + "ErrorDBFetchError": "Erreur de base de données : impossible de récupérer l'enregistrement : {0}", + "ErrorDBForeignKeyViolation": "Impossible de supprimer cet objet car il est relié à un ou plusieurs objets associés", + "ErrorDBRecordModifiedExternally": "Erreur de base de données : l'enregistrement du tableau {0} a été modifié par l'utilisateur {1} après que vous l'ayez ouvert et ne peut pas être actualisé.\\r\\nPour modifier ou supprimer cet enregistrement, vous devez d'abord le fermer puis le rouvrir.", + "ErrorDBSchemaMismatch": "Erreur : ce programme requiert la version de base de données {0} ; la base de données en cours d'ouverture correspond à la version {1}", + "ErrorGridFilterByOtherColumnNotSupported": "Le filtrage par comparaison aux valeurs d'autres colonnes ({0}) n'est pas pris en charge", + "ErrorLicenseExpired": "La licence AyaNova a expiré. L'accès sera limité en lecture seule pour tous les utilisateurs jusqu'à ce qu'une clé de licence valide soit saisie.\r\n\r\nPour acheter rapidement des licences à bon prix, visitez notre site Web, www.ayanova.com (en anglais).\r\n", + "ErrorLicenseWillExpire": "Attention : cette licence arrive à expiration {0}", + "ErrorLiteDatabase": "Error: AyaNova Lite can only be used with a standalone FireBird database", + "ErrorDuplicateNameWarning": "Warning: There is an existing item in the database with the same name", + "ErrorDuplicateSerialWarning": "Warning: There is an existing item in the database with the same serial number", + "ErrorFieldLengthExceeded": "{0} ne peut excéder {1} caractères", + "ErrorFieldLengthExceeded255": "{0} dépasse la limite de 255 caractères", + "ErrorFieldLengthExceeded500": "{0} dépasse la limite de 500 caractères", + "ErrorFieldValueNotBetween": "{0} non valide doit être compris entre {1} et {2}", + "ErrorFieldValueNotValid": "{0} n'est pas valide", + "ErrorNameFetcherNotFound": "Nom/opérateur booléen : Le champ {0} du tableau {1} et dont l'ID d'enregistrement est {2} est introuvable !", + "ErrorNotChangeable": "Erreur : impossible de modifier un objet de type {0}", + "ErrorNotDeleteable": "Erreur : impossible de supprimer un objet de type {0}", + "ErrorRequiredFieldEmpty": "{0} est un champ obligatoire. Veuillez saisir une valeur pour {0}", + "ErrorStartDateAfterEndDate": "La date de début doit être antérieure à la date de fin", + "ErrorSecurityAdministratorOnlyMessage": "L'accès à cette fonction requiert l'ouverture de session en tant qu'administrateur", + "ErrorSecurityNotAuthorizedToChange": "Erreur : soit l'utilisateur n'est pas autorisé à modifier un objet de type {0}, soit l'objet ou le champ en cours de modification est en lecture seule.", + "ErrorSecurityNotAuthorizedToCreate": "Erreur : l'utilisateur actuel n'est pas autorisé à créer un nouvel objet de type {0}", + "ErrorSecurityNotAuthorizedToDelete": "Erreur : l'utilisateur actuel n'est pas autorisé à supprimer un objet de type {0}", + "ErrorSecurityNotAuthorizedToDeleteDefaultObject": "Erreur : Impossible de supprimer l'objet de type {0} par défaut", + "ErrorSecurityNotAuthorizedToRetrieve": "Erreur : l'utilisateur actuel n'est pas autorisé à ouvrir un enregistrement {0}", + "ErrorSecurityUserCapacity": "Le nombre de licences disponibles est insuffisant pour poursuivre cette opération", + "ErrorTrialRestricted": "Le mode d'évaluation est limité à 30 bons de travail. Vous devez supprimer un bon de travail existant pour en ajouter un nouveau.\r\n\r\nPour vous procurer rapidement des licences à prix raisonnable, visitez notre site Web, à l'adresse www.ayanova.com.\r\n ", + "ErrorUnableToOpenDocumentUrl": "Impossible d'ouvrir le document", + "ErrorUnableToOpenEmailUrl": "Impossible d'ouvrir l'adresse e-mail", + "ErrorUnableToOpenWebUrl": "Impossible d'ouvrir l'adresse Web", + "FormFieldDataTypesCurrency": "Monnaie", + "FormFieldDataTypesDateOnly": "Date", + "FormFieldDataTypesDateTime": "Date et heure", + "FormFieldDataTypesNumber": "Numéro", + "FormFieldDataTypesText": "Texte", + "FormFieldDataTypesTimeOnly": "Heure", + "FormFieldDataTypesTrueFalse": "Vrai/Faux", + "GlobalAllowScheduleConflicts": "Autoriser les conflits de programme", + "GlobalAllowScheduleConflictsDescription": "Si l'utilisateur qui programme veut être averti dès qu'il empiète sur le programme d'un autre utilisateur, il doit régler cette valeur sur FAUX. S'il est fréquent d'empiéter sur les programmes, il est conseillé de régler cette valeur sur VRAI", + "GlobalCJKIndex": "Utiliser l'index CJC", + "GlobalCJKIndexDescription": "Ne réglez cette option sur VRAI que si des caractères chinois, japonais ou coréens doivent être saisis dans les champs et les étiquettes", + "GlobalCoordinateStyle": "Style d'affichage des coordonnées", + "GlobalCoordinateStyleDescription": "Détermine le mode d'affichage des coordonnées géographiques", + "GlobalDefaultLanguageDescription": "Langue qui sera affectée à toutes les étiquettes localisées", + "GlobalDefaultLatitude": "Hémisphère (latitude) par défaut des coordonnées", + "GlobalDefaultLatitudeDescription": "Hémisphère par défaut pour la nouvelle latitude", + "GlobalDefaultLongitude": "Hémisphère (longitude) par défaut des coordonnées", + "GlobalDefaultLongitudeDescription": "Hémisphère par défaut pour la nouvelle longitude", + "GlobalDefaultPartDisplayFormat": "Format d'affichage de pièce", + "GlobalDefaultPartDisplayFormatDescription": "Définit le format d'affichage des pièces pour la sélection", + "GlobalDefaultScheduleableUserNameDisplayFormat": "Format d'affichage de nom d'utilisateur", + "GlobalDefaultScheduleableUserNameDisplayFormatDescription": "Détermine le format d'affichage des utilisateurs programmables dans les boîtes déroulantes", + "GlobalDefaultServiceTemplateIDDescription": "Template used globally when no other more specific template is in effect", + "GlobalDefaultUnitNameDisplayFormat": "Format d'affichage d'unité", + "GlobalInventoryAdjustmentStartSeed": "Numéro de départ d'ajustement de stock", + "GlobalInventoryAdjustmentStartSeedDescription": "Le numéro de départ de l'ajustement de stock doit être supérieur aux numéros utilisés existants. Une fois qu'un numéro a été saisi, il est impossible de saisir un numéro inférieur", + "GlobalLaborSchedUserDfltTimeSpan": "Scheduled / Labor default minutes", + "GlobalLaborSchedUserDfltTimeSpanDescription": "Scheduled Users/Labor default time span for new records (minutes). 0 = off", + "GlobalMainGridAutoRefresh": "Auto-refresh main grids", + "GlobalMainGridAutoRefreshDescription": "Refresh main grid lists automatically every 5 minutes.", + "GlobalMaxFileSizeMB": "Maximum embedded file size", + "GlobalMaxFileSizeMBDescription": "Largest single file size in megabytes that can be stored embedded in the database", + "GlobalNotifySMTPAccount": "Nom d'utilisateur SMTP", + "GlobalNotifySMTPAccountDescription": "Compte d'accès au serveur de courrier SMTP", + "GlobalNotifySMTPFrom": "Adresse de réponse / d'expéditeur SMTP", + "GlobalNotifySMTPFromDescription": "Adresse e-mail d'expéditeur (de réponse) à utiliser lors de l'envoi de notifications sortantes", + "GlobalNotifySMTPHost": "Serveur SMTP", + "GlobalNotifySMTPHostDescription": "Serveur de courrier Internet (SMTP) utilisé pour l'envoi des messages de notification sortants", + "GlobalNotifySMTPPassword": "Mot de passe SMTP", + "GlobalNotifySMTPPasswordDescription": "Mot de passe du compte d'accès SMTP", + "GlobalPropertyCategoryDisplayStyle": "Style d'affichage", + "GlobalPurchaseOrderStartSeed": "Numéro de départ des bons de commande", + "GlobalPurchaseOrderStartSeedDescription": "Le numéro de départ des bons de commande doit être supérieur aux numéros utilisés existants. Une fois qu'un numéro a été saisi, il est impossible de saisir un numéro inférieur.", + "GlobalQuoteNumberStartSeed": "Numéro de départ des devis", + "GlobalQuoteNumberStartSeedDescription": "Le numéro de départ des devis doit être supérieur aux numéros utilisés existants. Une fois qu'un numéro a été saisi, il est impossible de saisir un numéro inférieur.", + "GlobalRentalStartSeed": "Numéro de série de location", + "GlobalRentalStartSeedDescription": "Le numéro de départ de location doit être supérieur aux numéros utilisés existants. Une fois qu'un numéro a été saisi, il est impossible de saisir un numéro inférieur.", + "GlobalSchedUserNonTodayStartTime": "Scheduled default time", + "GlobalSchedUserNonTodayStartTimeDescription": "Scheduled user default time for new records when choosing start date other than today.", + "GlobalSignatureFooter": "Signature footer", + "GlobalSignatureFooterDescription": "Text displayed as footer below signature box", + "GlobalSignatureHeader": "Signature header", + "GlobalSignatureHeaderDescription": "Text displayed as header above signature box", + "GlobalSignatureTitle": "Signature title", + "GlobalSignatureTitleDescription": "Text displayed as title above signature area", + "GlobalSMTPEncryption": "SMTP Encryption", + "GlobalSMTPEncryptionDescription": "Encryption method to use with SMTP server. Valid values are 'TLS', 'SSL' or empty for no encryption.", + "GlobalSMTPRetry": "SMTP Retry deliveries", + "GlobalSMTPRetryDescription": "Don't remove SMTP / SMS notifications if unable to connect to SMTP server; retry them again on next notification processing until delivered", + "GlobalSpellCheckDescription": "Si VRAI, tous les champs seront comparés à la liste de correction orthographique de la langue sélectionnée. Le réglage sur VRAI augmente le temps nécessaire pour l'enregistrement.", + "GlobalTaxPartPurchaseID": "Taxe par défaut sur l'achat de pièces", + "GlobalTaxPartPurchaseIDDescription": "Taxe sur les ventes utilisée par défaut sur les bons de commande", + "GlobalTaxPartSaleID": "Taxe par défaut sur les ventes de pièces", + "GlobalTaxPartSaleIDDescription": "Taxe sur les ventes utilisées par défaut pour les pièces sur les bons de travail", + "GlobalTaxRateSaleID": "Taxes de service par défaut", + "GlobalTaxRateSaleIDDescription": "Taxe sur les ventes utilisée par défaut pour les services sur les bons de travail", + "GlobalTravelDfltTimeSpan": "Travel default minutes", + "GlobalTravelDfltTimeSpanDescription": "Travel default time span for new records (minutes). 0 = off", + "GlobalUnitNameDisplayFormatsDescription": "Détermine le format d'affichage des unités dans les champs déroulants pour les bons de travail de service, les devis et les entretiens préventifs.", + "GlobalUseInventory": "Utiliser le stock", + "GlobalUseInventoryDescription": "FAUX limite l’accès à la saisie de pièces et à la sélection des pièces utilisées dans les bons de travail de service. VRAI autorise l’accès à toutes les fonctions liées aux stocks.", + "GlobalUseNotification": "Utiliser notification", + "GlobalUseNotificationDescription": "Si VRAI, active le système de notification. Si FAUX, désactive toutes les procédures de notification.", + "GlobalUseRegions": "Utiliser les régions", + "GlobalUseRegionsDescription": "Si VRAI, les utilisateurs affectés à une région ne pourront pas lire les informations concernant les utilisateurs d'autres régions", + "GlobalWorkorderCloseByAge": "Délai de fermeture du bon de travail (minutes)", + "GlobalWorkorderCloseByAgeDescription": "Délai (en minutes) entre la création du bon de travail et sa fermeture. Lorsqu'un bon de travail est créé, ce délai est ajouté à la date (et/ou l'heure) actuelle pour définir automatiquement la date de fermeture. À régler sur zéro en cas d'inutilisation.", + "GlobalWorkorderClosedStatus": "Workorder closed status", + "GlobalWorkorderClosedStatusDescription": "If a status is selected here, a work order will be set to this status automatically when closed by a user in AyaNova or AyaNovaWBI.", + "GlobalWorkorderNumberStartSeed": "Numéro de départ des bons de travail de service", + "GlobalWorkorderNumberStartSeedDescription": "Le numéro de départ du bon de travail de service doit être supérieur aux numéros utilisés existants. Une fois qu'un numéro a été saisi, il est impossible de saisir un numéro inférieur.", + "GlobalWorkorderSummaryTemplate": "Modèle de résumé d'élément de bon de travail", + "GlobalWorkorderSummaryTemplateDescription": "Cette option détermine les informations de bon de travail de service qui seront affichées dans l'écran de programmation", + "GridFilterName": "Filter name", + "HeadOfficeAccountNumber": "Numéro de compte", + "HeadOfficeContact": "Contact", + "HeadOfficeContactNotes": "Other contacts", + "HeadOfficeCustom0": "Champ personnalisé 0", + "HeadOfficeCustom1": "Champ personnalisé 1", + "HeadOfficeCustom2": "Champ personnalisé 2", + "HeadOfficeCustom3": "Champ personnalisé 3", + "HeadOfficeCustom4": "Champ personnalisé 4", + "HeadOfficeCustom5": "Champ personnalisé 5", + "HeadOfficeCustom6": "Champ personnalisé 6", + "HeadOfficeCustom7": "Champ personnalisé 7", + "HeadOfficeCustom8": "Champ personnalisé 8", + "HeadOfficeCustom9": "Champ personnalisé 9", + "HeadOfficeEmail": "Email", + "HeadOfficeList": "Sièges sociaux", + "HeadOfficeName": "Nom de siège social", + "HeadOfficeNotes": "Notes", + "HeadOfficePhone1": "Business", + "HeadOfficePhone2": "Fax", + "HeadOfficePhone3": "Home", + "HeadOfficePhone4": "Mobile", + "HeadOfficePhone5": "Pager", + "KeyNotFound": "Clé introuvable dans le presse-papier", + "KeyNotValid": "Impossible de valider la clé", + "KeySaved": "Clé enregistrée ; redémarrez AyaNova sur tous les ordinateurs", + "LoanItemCurrentWorkorderItemLoan": "ID de prêt d'élément de bon de travail actuel", + "LoanItemCustom0": "Champ personnalisé 0", + "LoanItemCustom1": "Champ personnalisé 1", + "LoanItemCustom2": "Champ personnalisé 2", + "LoanItemCustom3": "Champ personnalisé 3", + "LoanItemCustom4": "Champ personnalisé 4", + "LoanItemCustom5": "Champ personnalisé 5", + "LoanItemCustom6": "Champ personnalisé 6", + "LoanItemCustom7": "Champ personnalisé 7", + "LoanItemCustom8": "Champ personnalisé 8", + "LoanItemCustom9": "Champ personnalisé 9", + "LoanItemList": "Éléments de prêt", + "LoanItemName": "Nom", + "LoanItemNotes": "Notes", + "LoanItemRateDay": "Day rate", + "LoanItemRateHalfDay": "Half day rate", + "LoanItemRateHour": "Hour rate", + "LoanItemRateMonth": "Month rate", + "LoanItemRateNone": "-", + "LoanItemRateWeek": "Week rate", + "LoanItemRateYear": "Year rate", + "LoanItemSerial": "Numéro de série", + "LocaleCustomizeText": "Customize text", + "LocaleExport": "Exporter les paramètres régionaux dans le fichier", + "LocaleImport": "Importer les paramètres régionaux du fichier", + "LocaleList": "Ensemble de textes localisés", + "LocaleLocaleFile": "Fichier transportable de paramètres régionaux AyaNova (*.xml)", + "LocaleUIDestLocale": "Nouveau nom de paramètres régionaux", + "LocaleUISourceLocale": "Paramètres régionaux source", + "LocaleWarnLocaleLocked": "Your user account is using the \"English\" locale text.\r\nThis locale is read only and can not be edited.\r\nPlease change your locale in your user settings to any other value than \"English\" to proceed.", + "LocalizedTextDisplayText": "Texte standard", + "LocalizedTextDisplayTextCustom": "Texte personnalisé", + "LocalizedTextKey": "Clé", + "LocalizedTextLocale": "Langue", + "MemoForward": "Faire suivre", + "MemoReply": "Répondre", + "MemoEventCreated": "Mémo - entrant", + "MemoFromID": "De", + "MemoList": "Mémos", + "MemoMessage": "Message", + "MemoRe": "RE :", + "MemoReplied": "Répondu", + "MemoSent": "Envoyé", + "MemoSentRelative": "Envoyé (relatif)", + "MemoSubject": "Objet", + "MemoToID": "À", + "MemoViewed": "Lu", + "NotifyNotificationMessage": "Message", + "NotifySourceOfEvent": "Source", + "NotifyDeliveryLogDelivered": "Transmission effectuée", + "NotifyDeliveryLogDeliveryDate": "Transmis", + "NotifyDeliveryLogErrorMessage": "Message d'erreur", + "NotifyDeliveryLogList": "Notifications (7 derniers jours)", + "NotifyDeliveryLogToUser": "Transmis à", + "NotifyDeliveryMessageFormatsBrief": "Format compact de résumé", + "NotifyDeliveryMessageFormatsFull": "Format complet", + "NotifyDeliveryMethodsMemo": "Mémo AyaNova", + "NotifyDeliveryMethodsPopUp": "Boîte à messages contextuelle", + "NotifyDeliveryMethodsSMS": "Appareil compatible SMS", + "NotifyDeliveryMethodsSMTP": "Compte de courrier Internet", + "NotifyDeliverySettingAddress": "Adresse", + "NotifyDeliverySettingAllDay": "Toute la journée", + "NotifyDeliverySettingAnyTime": "Envoyer notification n'importe quand", + "NotifyDeliverySettingDeliver": "Livrer", + "NotifyDeliverySettingDeliveryMethod": "Méthode de transmission physique", + "NotifyDeliverySettingEndTime": "Date de fin", + "NotifyDeliverySettingEventWindows": "Envoyer notification à ces heures seulement :", + "NotifyDeliverySettingList": "Méthodes de notification", + "NotifyDeliverySettingMaxCharacters": "Caractères maximum", + "NotifyDeliverySettingMessageFormat": "Format de message", + "NotifyDeliverySettingName": "Nom", + "NotifyDeliverySettingStartTime": "Date de début", + "NotifySubscriptionCreated": "Abonné", + "NotifySubscriptionEventDescription": "Événement", + "NotifySubscriptionList": "Abonnements aux notifications", + "NotifySubscriptionPendingSpan": "Notifier avant l'événement", + "NotifySubscriptionWarningNoDeliveryMethod": "L'abonnement aux notifications requiert au moins une méthode de notification. Voulez-vous en définir une maintenant ?", + "NotifySubscriptionDeliveryUIAddNew": "Ajouter une méthode de transmission", + "Address": "Adresse", + "AssignedDoc": "Document", + "AyaFile": "Embedded file", + "Client": "Client", + "ClientGroup": "Groupe de clients", + "ClientNote": "Note client", + "ClientNoteType": "Type de note de client", + "ClientRequestPart": "Pièce demandée", + "ClientRequestTech": "Utilisateur programmable demandé", + "ClientRequestWorkorder": "Bon de travail demandé", + "ClientRequestWorkorderItem": "Élément de bon de travail demandé", + "ClientServiceRequest": "Demande de service client", + "ClientServiceRequestItem": "Élément de demande de service client", + "Contact": "Contact", + "ContactPhone": "Téléphone de contact", + "ContactTitle": "Titre de contact", + "Contract": "Contrat", + "ContractPart": "Pièce de contrat", + "ContractRate": "Tarif de contrat", + "DispatchZone": "Zone d'expédition", + "Global": "Général", + "GlobalWikiPage": "Global Wiki page", + "GridFilter": "GridFilter", + "HeadOffice": "Siège social", + "LoanItem": "Élément de prêt", + "Locale": "Paramètres linguistiques", + "LocalizedText": "Texte localisé", + "Maintenance": "Maintenance interne AyaNova", + "Memo": "Mémo", + "NameFetcher": "Objet NameFetcher", + "Notification": "Notification", + "NotifySubscription": "Abonnement aux notifications", + "NotifySubscriptionDelivery": "Méthode de notification", + "Part": "Pièce", + "PartAssembly": "Assemblage de pièce", + "PartByWarehouseInventory": "Pièce par stock de magasin", + "PartCategory": "Catégorie de pièces", + "PartInventoryAdjustment": "Ajustement de stock de pièces", + "PartInventoryAdjustmentItem": "Élément d'ajustement de stock de pièces", + "PartSerial": "Pièce numérotée", + "PartWarehouse": "Magasin de pièces", + "PreventiveMaintenance": "Entretien préventif", + "Priority": "Priorité", + "Project": "Projet", + "PurchaseOrder": "Bon de commande", + "PurchaseOrderItem": "Élément de bon de commande", + "PurchaseOrderReceipt": "Reçu de bon de commande", + "PurchaseOrderReceiptItem": "Élément de reçu de bon de commande", + "Rate": "Tarif", + "RateUnitChargeDescription": "Description de tarif unitaire", + "Region": "Région", + "Rental": "Location", + "RentalUnit": "Unité de location", + "Report": "Rapport", + "ScheduleableUserGroup": "Groupes d'utilisateurs programmables", + "ScheduleableUserGroupUser": "Utilisateur de groupe d'utilisateurs programmables", + "ScheduleForm": "Formulaire de programme", + "ScheduleMarker": "Marqueur de programmation", + "SecurityGroup": "Groupe de sécurité", + "ServiceBank": "Services prépayés", + "Task": "Tâche", + "TaskGroup": "Groupe de tâches", + "TaskGroupTask": "Tâche de groupe de tâches", + "TaxCode": "Code de taxe", + "Unit": "Unité", + "UnitMeterReading": "Lecture de compteur d'unités", + "UnitModel": "Modèle d'unité", + "UnitModelCategory": "Catégorie de modèles d'unité", + "UnitOfMeasure": "Unité de mesure", + "UnitServiceType": "Type de service d'unité", + "User": "Utilisateur", + "UserCertification": "Certification d'utilisateur", + "UserCertificationAssigned": "CertificationUtilisateurAttribuée", + "UserRight": "Objet UserRight", + "UserSkill": "Aptitude d'utilisateur", + "UserSkillAssigned": "Aptitude d'utilisateur attribuée", + "Vendor": "Fournisseur", + "WikiPage": "Wiki page", + "Workorder": "Bon de travail", + "WorkorderClose": "Close work order", + "WorkorderCategory": "Catégorie de bons de travail", + "WorkorderItem": "Élément de bon de travail", + "WorkorderItemLabor": "Main d'oeuvre d'élément de bon de travail", + "WorkorderItemLoan": "Prêt d'élément de bon de travail", + "WorkorderItemMiscExpense": "Dépenses diverses d'élément de bon de travail", + "WorkorderItemPart": "Pièce d'élément de bon de travail", + "WorkorderItemPartRequest": "Demande de pièce d'élément de bon de travail", + "WorkorderItemScheduledUser": "Utilisateur programmé d'élément de bon de travail", + "WorkorderItemTask": "Tâche d'élément de bon de travail", + "WorkorderItemTravel": "Déplacement d'élément de bon de travail", + "WorkorderItemType": "Type d'élément de bon de travail", + "WorkorderItemUnit": "Workorder item unit", + "WorkorderPreventiveMaintenance": "Entretien préventif", + "WorkorderPreventiveMaintenanceTemplate": "Preventive maintenance template", + "WorkorderQuote": "Devis", + "WorkorderQuoteTemplate": "Quote template", + "WorkorderService": "Bon de travail", + "WorkorderServiceTemplate": "Service template", + "WorkorderStatus": "État de bon de travail", + "ObjectCustomFieldCustomGrid": "Champs personnalisés", + "ObjectCustomFieldDisplayName": "Afficher comme", + "ObjectCustomFieldFieldName": "Nom de champ", + "ObjectCustomFieldFieldType": "Type de données de champ", + "ObjectCustomFieldObjectName": "Nom d'objet", + "ObjectCustomFieldVisible": "Visible", + "OutsideServiceList": "Liste des services extérieurs", + "PartMustTrackSerial": "Impossible de régler les numéros de série de suivi sur Faux, car cette pièce apparaît déjà dans l'historique avec des numéros de série", + "PartTrackSerialHasInventory": "Track serial numbers can not be turned on as this part still has items in inventory", + "PartAlert": "Texte d'alerte", + "PartAlternativeWholesalerID": "Grossiste de remplacement", + "PartAlternativeWholesalerNumber": "Numéro de grossiste de remplacement", + "PartCustom0": "Champ personnalisé 0", + "PartCustom1": "Champ personnalisé 1", + "PartCustom2": "Champ personnalisé 2", + "PartCustom3": "Champ personnalisé 3", + "PartCustom4": "Champ personnalisé 4", + "PartCustom5": "Champ personnalisé 5", + "PartCustom6": "Champ personnalisé 6", + "PartCustom7": "Champ personnalisé 7", + "PartCustom8": "Champ personnalisé 8", + "PartCustom9": "Champ personnalisé 9", + "PartList": "Pièces", + "PartManufacturerID": "Fabricant", + "PartManufacturerNumber": "Numéro de fabricant", + "PartName": "Nom de pièce", + "PartNotes": "Notes", + "PartPartNumber": "Numéro de pièce", + "PartRetail": "Détail", + "PartTrackSerialNumber": "Numéro de série de suivi", + "PartUPC": "CUP", + "PartWholesalerID": "Grossiste", + "PartWholesalerNumber": "Numéro de grossiste", + "PartAssemblyDescription": "Description", + "PartAssemblyList": "Assemblages de pièces", + "PartAssemblyName": "Nom d'assemblage de pièce", + "PartByWarehouseInventoryList": "Stock de pièces", + "PartByWarehouseInventoryMinStockLevel": "Niveau de réassortiment", + "PartByWarehouseInventoryQtyOnOrderCommitted": "Quantité en commande validée", + "PartByWarehouseInventoryQuantityOnHand": "Disponible", + "PartByWarehouseInventoryQuantityOnOrder": "En commande", + "PartByWarehouseInventoryReorderQuantity": "Quantité de réapprovisionnement", + "PartCategoryList": "Catégories de pièces", + "PartCategoryName": "Nom de catégorie de pièces", + "PartDisplayFormatsAssemblyNumberName": "Assemblage - numéro - nom", + "PartDisplayFormatsCategoryNumberName": "Catégorie - numéro - nom", + "PartDisplayFormatsManufacturerName": "Fabricant - nom", + "PartDisplayFormatsManufacturerNumber": "Fabricant - numéro", + "PartDisplayFormatsName": "Nom uniquement", + "PartDisplayFormatsNameCategoryNumberManufacturer": "Name - category - number - manufacturer", + "PartDisplayFormatsNameNumber": "Nom - numéro", + "PartDisplayFormatsNameNumberManufacturer": "Name - number - manufacturer", + "PartDisplayFormatsNameUPC": "Nom - CUP", + "PartDisplayFormatsNumber": "Numéro uniquement", + "PartDisplayFormatsNumberName": "Numéro - nom", + "PartDisplayFormatsNumberNameManufacturer": "Numéro - nom - fabricant", + "PartDisplayFormatsUPC": "CUP uniquement", + "PartInventoryAdjustmentAdjustmentNumber": "Numéro", + "PartInventoryAdjustmentDateAdjusted": "Date d'ajustement", + "PartInventoryAdjustmentPartInventoryAdjustmentID": "ID d'ajustement", + "PartInventoryAdjustmentReasonForAdjustment": "Raison", + "PartInventoryAdjustmentItemNegativeQuantityInvalid": "Il n'y a pas assez (ou pas du tout) de pièces de ce type dans ce magasin pour que l'on puisse les retirer du stock", + "PartInventoryAdjustmentItemPartNotUnique": "La même combinaison pièce/magasin ne peut être utilisée qu'une fois dans le cadre d'un ajustement", + "PartInventoryAdjustmentItemZeroQuantityInvalid": "Vous devez spécifier une quantité", + "PartInventoryAdjustmentItemQuantityAdjustment": "Ajustement de quantité", + "PartRestockRequiredByVendorList": "Réassortiment de pièces demandé par le fournisseur", + "PartSerialAdjustmentID": "Ajustement", + "PartSerialAvailable": "Disponible", + "PartSerialDateConsumed": "Consommé", + "PartSerialDateReceived": "Réceptionné", + "PartSerialSerialNumberNotUnique": "Numéro de série déjà saisi pour cette pièce", + "PartSerialWarehouseID": "Magasin de pièces", + "PartWarehouseDescription": "Description", + "PartWarehouseList": "Magasins de pièces", + "PartWarehouseName": "Nom de magasin de pièces", + "PriorityColor": "Couleur", + "PriorityList": "Priorités", + "PriorityName": "Nom de priorité", + "ProjectAccountNumber": "Numéro de compte", + "ProjectCustom0": "Champ personnalisé 0", + "ProjectCustom1": "Champ personnalisé 1", + "ProjectCustom2": "Champ personnalisé 2", + "ProjectCustom3": "Champ personnalisé 3", + "ProjectCustom4": "Champ personnalisé 4", + "ProjectCustom5": "Champ personnalisé 5", + "ProjectCustom6": "Champ personnalisé 6", + "ProjectCustom7": "Champ personnalisé 7", + "ProjectCustom8": "Champ personnalisé 8", + "ProjectCustom9": "Champ personnalisé 9", + "ProjectDateCompleted": "Date de fin", + "ProjectDateStarted": "Date de début", + "ProjectList": "Projets", + "ProjectName": "Nom de projet", + "ProjectNotes": "Notes", + "ProjectProjectOverseerID": "Responsable de projet", + "PurchaseOrderActualReceiveDate": "Date prévue", + "PurchaseOrderCustom0": "Champ personnalisé 0", + "PurchaseOrderCustom1": "Champ personnalisé 1", + "PurchaseOrderCustom2": "Champ personnalisé 2", + "PurchaseOrderCustom3": "Champ personnalisé 3", + "PurchaseOrderCustom4": "Champ personnalisé 4", + "PurchaseOrderCustom5": "Champ personnalisé 5", + "PurchaseOrderCustom6": "Champ personnalisé 6", + "PurchaseOrderCustom7": "Champ personnalisé 7", + "PurchaseOrderCustom8": "Champ personnalisé 8", + "PurchaseOrderCustom9": "Champ personnalisé 9", + "PurchaseOrderDropShipToClientID": "Livraison directe au client", + "PurchaseOrderLocked": "Le bon de commande est bloqué en raison de son état", + "PurchaseOrderExpectedReceiveDate": "Réception prévue", + "PurchaseOrderNotes": "Notes", + "PurchaseOrderOrderedDate": "Date de commande", + "PurchaseOrderPONumber": "Numéro de bon de commande", + "PurchaseOrderStatusClosedFullReceived": "Fermé - totalement réceptionné", + "PurchaseOrderStatusClosedNoneReceived": "Fermé - non réceptionné", + "PurchaseOrderStatusClosedPartialReceived": "Fermé - partiellement réceptionné", + "PurchaseOrderStatusOpenNotYetOrdered": "Ouvert - pas encore commandé", + "PurchaseOrderStatusOpenOrdered": "Ouvert - en commande", + "PurchaseOrderStatusOpenPartialReceived": "Ouvert - partiellement réceptionné", + "PurchaseOrderReferenceNumber": "Numéro de référence", + "PurchaseOrderShowPartsAllVendors": "Select from any vendor's part", + "PurchaseOrderStatus": "État de bon de commande", + "PurchaseOrderUICopyToPurchaseOrder": "Copier dans le bon de commande", + "PurchaseOrderUINoPartsForVendorWarning": "Le fournisseur sélectionné ne possède pas de pièces définies dans AyaNova. Vous ne pourrez saisir aucun élément de bon de commande pour ce fournisseur.", + "PurchaseOrderUIOrderedWarning": "Souhaitez-vous réellement régler l'état de ce bon de commande sur Commandé ?", + "PurchaseOrderUIRestockList": "Liste de réassortiment", + "PurchaseOrderVendorMemo": "Mémo fournisseur", + "PurchaseOrderItemClosed": "Fermé", + "PurchaseOrderItemLineTotal": "Total de la ligne", + "PurchaseOrderItemNetTotal": "Total net", + "PurchaseOrderItemPartName": "Nom de pièce", + "PurchaseOrderItemPartNumber": "Numéro de pièce", + "PurchaseOrderItemPartRequestedByID": "Demandé par", + "PurchaseOrderItemPurchaseOrderCost": "Coût de bon de commande", + "PurchaseOrderItemQuantityOrdered": "Quantité commandée", + "PurchaseOrderItemQuantityReceived": "Quantité réceptionnée", + "PurchaseOrderItemUIOrderedFrom": "Commandé chez", + "PurchaseOrderItemUISaveWarning": "Voulez-vous réellement sauvegarder ? Une fois l'enregistrement sauvegardé, il sera bloqué et ne pourra plus être modifié.", + "PurchaseOrderItemWorkorderNumber": "Bon de travail n° ", + "PurchaseOrderReceiptItems": "Éléments de reçu de bon de commande", + "PurchaseOrderReceiptPartRequestNotFound": "Demande de pièce introuvable", + "PurchaseOrderReceiptReceivedDate": "Date de réception", + "PurchaseOrderReceiptText1": "Text1", + "PurchaseOrderReceiptText2": "Text2", + "PurchaseOrderReceiptItemPONumber": "Bon de commande", + "PurchaseOrderReceiptItemPurchaseOrderItemID": "Élément de bon de commande", + "PurchaseOrderReceiptItemPurchaseOrderReceiptID": "Reçu de bon de commande", + "PurchaseOrderReceiptItemQuantityReceived": "Quantité réceptionnée", + "PurchaseOrderReceiptItemQuantityReceivedErrorInvalid": "Cette entrée doit être positive et égale ou inférieure à l'encours du bon de commande", + "PurchaseOrderReceiptItemReceiptCost": "Coût réel", + "PurchaseOrderReceiptItemReferenceNumber": "Référence", + "PurchaseOrderReceiptItemWarehouseID": "Magasin de pièces", + "PurchaseOrderReceiptItemWorkorderNumber": "Bon de travail n° ", + "RateAccountNumber": "Numéro de compte", + "RateCharge": "Prix de détail", + "RateClientGroupID": "Groupe de clients", + "RateContractRate": "Tarif de contrat", + "RateDescription": "Description", + "RateList": "Tarifs", + "RateName": "Nom de tarif", + "RateRateType": "Type de tarif", + "RateRateTypeRental": "Location", + "RateRateTypeService": "Service", + "RateRateTypeTravel": "Déplacement", + "RateRateUnitChargeDescriptionID": "Description de tarif unitaire", + "RateUnitChargeDescriptionList": "Unités de tarification", + "RateUnitChargeDescriptionName": "Nom de description de tarif unitaire", + "RateUnitChargeDescriptionNamePlural": "Plural name", + "RegionAttachQuote": "Attach quote report", + "RegionAttachWorkorder": "Attach workorder report", + "RegionClientNotifyMessage": "Message to send to client", + "RegionCSRAccepted": "CSR accepted", + "RegionCSRRejected": "CSR rejected", + "RegionDefaultPurchaseOrderTemplate": "Modèle de bon de commande par défaut", + "RegionDefaultQuoteTemplate": "Modèle de devis par défaut", + "RegionDefaultWorkorderTemplate": "Modèle de bon de travail par défaut", + "RegionFollowUpDays": "Days after work order closed", + "RegionList": "Régions", + "RegionName": "Nom de région", + "RegionNewWO": "New WO", + "RegionQuoteStatusChanged": "Quote status changed", + "RegionReplyToEmailAddress": "Reply to email address", + "RegionWBIUrl": "AyaNova WBI url address", + "RegionWOClosedEmailed": "WO Closed", + "RegionWOFollowUp": "WO follow up", + "RegionWorkorderClosedStatus": "État fermé du bon de travail", + "RegionWOStatusChanged": "WO status changed", + "ReportActive": "Actif", + "ReportDesignReport": "Design", + "ReportImportDuplicate": "Impossible d'importer le rapport sélectionné : votre base de données contient déjà un rapport portant cet ID interne", + "ReportExport": "Exporter vers...", + "ReportExportHTML": "Fichier HTML (*.html)", + "ReportExportPDF": "Fichier Acrobat (*.pdf)", + "ReportExportRTF": "Fichier RTF (*.rtf)", + "ReportExportTIFF": "Fichier TIFF (*.tif)", + "ReportExportTXT": "Fichier texte (*.txt)", + "ReportExportXLS": "Fichier Excel (*.xls)", + "ReportExportLayout": "Modèle d'exportation", + "ReportExportLayoutFile": "Fichier transportable de rapport AyaNova (*.ayr)", + "ReportImportLayout": "Modèle d'importation", + "ReportList": "Modèles de rapport", + "ReportMaster": "Rapport principal", + "ReportMasterWarning": "Rapport principal - lecture seule", + "ReportName": "Nom", + "ReportNewDetailedReport": "Nouveau modèle de rapport détaillé", + "ReportNewSummaryReport": "Nouveau modèle de rapport résumé", + "ReportReportKey": "Clé", + "ReportReportSize": "Taille (octets)", + "ReportSaveAsDialogTitle": "Enregistrer le modèle de rapport sous...", + "ReportSecurityGroupID": "Restrict to security group", + "ReportEditorControls": "Boîte à outils", + "ReportEditorExplorer": "Explorateur", + "ReportEditorFields": "Champs", + "ReportEditorProperties": "Propriétés", + "ScheduleableUserGroupDescription": "Description", + "ScheduleableUserGroupList": "Groupes d'utilisateurs programmables", + "ScheduleableUserGroupName": "Nom de groupe d'utilisateurs programmables", + "ScheduleableUserGroupScheduleableUsers": "Utilisateurs programmables", + "ScheduleableUserGroupUserScheduleableUserGroupID": "ID de groupe d'utilisateurs programmables", + "ScheduleableUserGroupUserScheduleableUserID": "Utilisateur programmable", + "ScheduleableUserNameDisplayFormatsEmployeeNumberFirstLast": "Numéro d'employé - Prénom Nom", + "ScheduleableUserNameDisplayFormatsEmployeeNumberInitials": "Numéro d'employé - initiales", + "ScheduleableUserNameDisplayFormatsFirstLast": "Prénom Nom", + "ScheduleableUserNameDisplayFormatsFirstLastRegion": "First Last - Region", + "ScheduleableUserNameDisplayFormatsInitials": "Initiales", + "ScheduleableUserNameDisplayFormatsLastFirst": "Nom, prénom", + "ScheduleableUserNameDisplayFormatsLastFirstRegion": "Last, First - Region", + "ScheduleableUserNameDisplayFormatsRegionFirstLast": "Region - First Last", + "ScheduleableUserNameDisplayFormatsRegionLastFirst": "Region - Last, First", + "ScheduledList": "Liste programmée", + "ScheduleMarkerARGB": "ARVB", + "ScheduleMarkerColor": "Couleur", + "ScheduleMarkerCompleted": "Completed", + "ScheduleMarkerEventCreated": "Marqueur de programmation - Récemment créé", + "ScheduleMarkerEventPendingAlert": "Marqueur de programmation - Événement imminent", + "ScheduleMarkerFollowUp": "Follow up", + "ScheduleMarkerList": "Schedule markers", + "ScheduleMarkerName": "Nom", + "ScheduleMarkerNotes": "Notes", + "ScheduleMarkerRecurrence": "Répétition", + "ScheduleMarkerScheduleMarkerSourceType": "Pour", + "ScheduleMarkerSourceID": "Source", + "ScheduleMarkerStartDate": "Début", + "ScheduleMarkerStopDate": "Fin", + "SearchResultDescription": "Description", + "SearchResultExtract": "Extrait", + "SearchResultRank": "Rang", + "SearchResultSource": "Source", + "SecurityGroupList": "Groupes de sécurité", + "SecurityGroupName": "Nom de groupe de sécurité", + "SecurityLevelTypesNoAccess": "Interdit", + "SecurityLevelTypesReadOnly": "Lecture seule", + "SecurityLevelTypesReadWrite": "Lecture / écriture", + "SecurityLevelTypesReadWriteDelete": "Lecture / écriture / suppression", + "ServiceBankAppliesToRootObjectID": "S'applique à l'objet racine", + "ServiceBankAppliesToRootObjectType": "S'applique au type d'objet racine", + "ServiceBankCreated": "Validé", + "ServiceBankCreator": "Utilisateur", + "ServiceBankCurrency": "Monnaie", + "ServiceBankCurrencyBalance": "Solde monétaire", + "ServiceBankDescription": "Description", + "ServiceBankEffectiveDate": "Date effective", + "ServiceBankEventCurrencyBalanceZero": "Service prépayé - solde d'argent nul", + "ServiceBankEventHoursBalanceZero": "Service prépayé - solde des heures nul", + "ServiceBankEventIncidentsBalanceZero": "Service prépayé – solde des incidents nul", + "ServiceBankHours": "Heures", + "ServiceBankHoursBalance": "Solde des heures", + "ServiceBankID": "ID", + "ServiceBankIncidents": "Incidents", + "ServiceBankIncidentsBalance": "Solde des incidents", + "ServiceBankList": "Liste des services prépayés", + "ServiceBankSourceRootObjectID": "ID source", + "ServiceBankSourceRootObjectType": "Source", + "StopWords1": "quelle quelles quels qui sa sans ses seulement si sien son sont sous soyez sujet su ta tandis tellement tels tes ton tous tout top tès tu valeu voie voient vont vote vous vu ça étaient état étions été ête", + "StopWords2": "alos au aucuns aussi aute avant avec avoi bon ca ce cela ces ceux chaque ci comme comment dans des du dedans dehos depuis deux devait doit donc dos doite début elle elles en encoe essai est et eu fait faites fois", + "StopWords3": "font foce haut hos ici il ils je juste la le les leu là ma maintenant mais mes mine moins mon mot même ni nommés note nous nouveaux ou où pa pace paole pas pesonnes peut peu pièce plupat pou pouquoi quand que quel", + "StopWords4": "?", + "StopWords5": "?", + "StopWords6": "?", + "StopWords7": "?", + "TaskList": "Tâches", + "TaskName": "Nom de tâche", + "TaskGroupDescription": "Description", + "TaskGroupList": "Groupes de tâches", + "TaskGroupName": "Nom de groupe de tâches", + "TaskGroupTaskTaskGroupID": "Groupe de tâches", + "TaxCodeDefault": "Erreur : impossible de supprimer ou de désactiver ce code de taxe s'il correspond à un réglage par défaut dans les Réglages généraux", + "TaxCodeList": "Codes de taxe", + "TaxCodeName": "Nom de code de taxe", + "TaxCodeNotes": "Notes", + "TaxCodeTaxA": "Taxe “A”", + "TaxCodeTaxAExempt": "Exemption de taxe “A”", + "TaxCodeTaxAValue": "Valeur taxe A", + "TaxCodeTaxB": "Taxe “B”", + "TaxCodeTaxBExempt": "Exemption de taxe “B”", + "TaxCodeTaxBValue": "Valeur taxe B", + "TaxCodeTaxOnTax": "Taxe sur taxe", + "Add": "Ajouter", + "Cancel": "Annuler", + "Close": "Quitter", + "Closed": "Modifier l'état fermé", + "CurrentDateAndTime": "Date et heure actuelles", + "CustomFieldDesign": "Design de champ personnalisé", + "Delete": "Supprimer", + "Duplicate": "Dupliquer", + "Edit": "Edit", + "ExternalTools": "External tools", + "LocalizedTextDesign": "Design de texte localisé", + "OK": "OK", + "Open": "Ouvrir", + "Ordered": "Commandé", + "Paste": "Coller", + "RecordHistory": "Enregistrer l'historique", + "Save": "Enregistrer", + "SaveClose": "Enregistrer et quitter", + "SaveNew": "Enregistrer et nouveau", + "Search": "Rechercher", + "ServiceHistory": "Historique de service", + "Administration": "Administration", + "AdministrationGlobalSettings": "Réglages généraux", + "Home": "Home", + "Inventory": "Stock", + "InventoryPartInventoryAdjustments": "Ajustements", + "InventoryPartInventoryAdjustmentsDetailed": "Items", + "InventoryPurchaseOrderReceipts": "Reçus de bon de commande", + "InventoryPurchaseOrderReceiptsReceive": "Inventaire de réception", + "InventoryPurchaseOrderReceiptsDetailed": "Items", + "InventoryPurchaseOrders": "Bons de commande", + "InventoryPurchaseOrdersDetailed": "Items", + "Logout": "Fermer la session", + "Quotes": "Devis", + "Schedule": "Programmer", + "Service": "Service", + "ServicePreventiveMaintenance": "EP", + "ServiceQuotes": "Devis", + "UnitModels": "Modèles d'unité", + "UserMail": "Courrier", + "UserPreferences": "Préférences d'utilisateur", + "UserPunchClock": "Pointeuse d'utilisateur", + "VendorsSubContractors": "Sous-traitants", + "VendorsWholesalers": "Grossistes", + "GridFilterDialogAddConditionButtonText": "&Ajouter une condition", + "GridFilterDialogAndRadioText": "Conditions Et", + "GridFilterDialogCancelButtonText": "&Annuler", + "GridFilterDialogDeleteButtonText": "Supprimer la condition", + "GridFilterDialogOkButtonNoFiltersText": "S&ans filtres", + "GridFilterDialogOkButtonText": "&OK", + "GridFilterDialogOrRadioText": "Conditions Ou", + "GridRowFilterDialogBlanksItem": "(Vides)", + "GridRowFilterDialogDBNullItem": "(DBNull)", + "GridRowFilterDialogEmptyTextItem": "(Texte vide)", + "GridRowFilterDialogOperandHeaderCaption": "Opérande", + "GridRowFilterDialogOperatorHeaderCaption": "Opérateur", + "GridRowFilterDialogTitlePrefix": "Saisir critère de filtre pour", + "GridRowFilterDropDownAllItem": "(Tous)", + "GridRowFilterDropDownBlanksItem": "(Vides)", + "GridRowFilterDropDownCustomItem": "(Personnalisé)", + "GridRowFilterDropDownEquals": "Égal à", + "GridRowFilterDropDownGreaterThan": "Supérieur à", + "GridRowFilterDropDownGreaterThanOrEqualTo": "Supérieur ou égal à", + "GridRowFilterDropDownLessThan": "Inférieur à", + "GridRowFilterDropDownLessThanOrEqualTo": "Inférieur ou égal à", + "GridRowFilterDropDownLike": "Comme", + "GridRowFilterDropDownMatch": "Correspond à l'expression régulière", + "GridRowFilterDropDownNonBlanksItem": "(Non vides)", + "GridRowFilterDropDownNotEquals": "N'est pas égal à", + "GridRowFilterRegexError": "Erreur d'analyse de l'expression régulière {0}. Veuillez saisir une expression régulière valide.", + "GridRowFilterRegexErrorCaption": "Expression régulière invalide", + "HelpAboutAyaNova": "À propos d'AyaNova", + "HelpCheckForUpdates": "Rechercher des mises à jour", + "HelpContents": "&Contenu...", + "HelpLicense": "License", + "HelpPurchaseLicenses": "Acheter des licences", + "HelpTechSupport": "Assistance technique", + "AllDay": "All day", + "AnyUser": "All users", + "ComboMoreRecordsPrompt": "< Plus... >", + "CopyOfText": "Copie de", + "DateRange14DayWindow": "Fenêtre - 14 jours", + "DateRangeFuture": "Future", + "DateRangeInTheLastSixMonths": "In the last 6 months", + "DateRangeInTheLastThreeMonths": "In the last 3 months", + "DateRangeInTheLastYear": "In the last year", + "DateRangeLastMonth": "Mois - Précédent", + "DateRangeLastWeek": "Week - Previous", + "DateRangeLastYear": "Year - Last", + "DateRangeNextMonth": "Mois - Suivant", + "DateRangeNextWeek": "Semaine - Prochaine", + "DateRangePast": "Past", + "DateRangeThisMonth": "Mois - Actuel", + "DateRangeThisWeek": "Semaine - Actuelle", + "DateRangeThisYear": "Year - Current", + "DateRangeToday": "Aujourd'hui", + "DateRangeTomorrow": "Demain", + "DateRangeYesterday": "Yesterday", + "DayAny": "N'importe quel jour de la semaine", + "DayFriday": "Vendredi", + "DayMonday": "Lundi", + "DaySaturday": "Samedi", + "DaySunday": "Dimanche", + "DayThursday": "Jeudi", + "DayTuesday": "Mardi", + "DayWednesday": "Mercredi", + "DayOfWeek": "Jour de semaine", + "DeletePrompt": "Souhaitez-vous réellement supprimer définitivement cet enregistrement ?", + "DeleteWorkorderPrompt": "Are you sure you want to delete this Workorder permanently?", + "East": "Est", + "FilterAvailableTo": "Available to", + "Filtered": "Filtré", + "FilterNone": "No filter", + "FilterUnsaved": "Unsaved filter", + "Find": "Rechercher", + "FindAndReplace": "Rechercher et remplacer", + "FirstRows100": "First 100 rows", + "FirstRows1000": "First 1000 rows", + "FirstRows500": "First 500 rows", + "FirstRowsAll": "All rows", + "LastServiceDate": "Date de dernière fermeture du service", + "LastWorkorder": "Dernier bon de travail de service fermé", + "LineTotal": "Total de la ligne", + "NetValue": "Net", + "North": "Nord", + "Object": "Objet", + "Replace": "Remplacer", + "SavePrompt": "Voulez-vous enregistrer les modifications ?", + "SelectItem": "Sélectionner", + "SelectPurchaseOrdersToReceive": "Sélectionner les bons de commande à recevoir...", + "SelectVendor": "Sélectionner un fournisseur...", + "SetLoginPassword": "Définir Nom d'utilisateur et Mot de passe", + "ShowAll": "Show all...", + "South": "Sud", + "SubTotal": "Sous-total", + "TimeSpanDays": "jours", + "TimeSpanFutureRelative": "à partir de maintenant", + "TimeSpanHours": "heures", + "TimeSpanMinutes": "minutes", + "TimeSpanMonths": "mois", + "TimeSpanPastRelative": "auparavant", + "TimeSpanSeconds": "secondes", + "TimeSpanWeeks": "semaines", + "TimeSpanWithinTheHour": "Within the hour", + "TimeSpanYears": "années", + "Total": "Total", + "UnsaveableDueToBrokenRulesPrompt": "Impossible de sauvegarder cet enregistrement car il enfreint une ou plusieurs règles. \r\n\r\nContinuer sans sauvegarder ?", + "West": "Ouest", + "MenuGo": "Navigation", + "MenuHelp": "Aide", + "MenuMRU": "Recent...", + "MenuSubGrids": "Sous-grilles", + "RecordHistoryCreated": "Date de création de cet enregistrement", + "RecordHistoryCreator": "Créateur de cet enregistrement", + "RecordHistoryModified": "Date de la dernière modification de cet enregistrement", + "RecordHistoryModifier": "Dernier utilisateur ayant modifié cet enregistrement", + "AddRemoveButtons": "Ajouter et supprimer des boutons....", + "ClientsToolBar": "Barre d'outils Clients", + "Customize": "Personnaliser....", + "CustomizeDialog_AlwaysShowFullMenus": "Toujours afficher les menus complets", + "CustomizeDialog_CloseNoAmp": "Fermer", + "CustomizeDialog_FloatingToolbarFadeDelay": "Durée de fondu de la barre d'outils flottante", + "CustomizeDialog_KeyboardBeginAmp": "Clavier....", + "CustomizeDialog_LargeIconsOnMenus": "Menus à grandes icônes", + "CustomizeDialog_LargeIconsOnToolbars": "Barres d'outils à grandes icônes", + "CustomizeDialog_Milliseconds": "Millisecondes", + "CustomizeDialog_New": "Nouveau....", + "CustomizeDialog_Other": "Autre", + "CustomizeDialog_PersonalizedMenusAndToolbars": "Menus et barres d'outils personnalisés", + "CustomizeDialog_Rename": "Renommer....", + "CustomizeDialog_ResetAmp": "Réinitialiser....", + "CustomizeDialog_ResetMyUsageData": "Réinitialiser mes données d'utilisation", + "CustomizeDialog_SelectedCommand": "Commande sélectionnée :", + "CustomizeDialog_ShowFullMenusAfterAShortDelay": "Afficher les menus complets après un court délai", + "CustomizeDialog_ShowScreenTipsOnToolbars": "Afficher les conseils sur les barres d'outils", + "CustomizeDialog_ShowShortcutKeysInScreenTips": "Afficher les raccourcis clavier dans les conseils à l'écran", + "CustomizeDlgKbdCat": "Catégories :", + "CustomizeDlgKbdCmd": "Commandes", + "MainMenuBar": "Barre de menus principale", + "MainToolBar": "Barre d'outils principale", + "New": "Nouveau....", + "PageSetup": "Page setup", + "Print": "Imprimer", + "PrintPreview": "Print preview", + "Refresh": "Actualiser...", + "ResetToolbar": "Réinitialiser la barre d'outils", + "ScheduleActiveWorkorderItem": "Élément de bon travail actif :", + "ScheduleAddToActiveWorkorderItem": "Ajouter la sélection à l'élément de bon de travail actif", + "ScheduleDayView": "Affichage Journée unique", + "ScheduleEditScheduleableUserGroup": "Modifier les groupes d'utilisateurs programmables", + "ScheduleEditScheduleMarker": "Modifier le marqueur de programmation sélectionné", + "ScheduleEditWorkorder": "Modifier le bon de travail sélectionné", + "ScheduleMergeUsers": "Affichage fusionné / séparé", + "ScheduleMonthView": "Affichage Mois", + "ScheduleNewScheduleMarker": "Nouveau marqueur de programmation", + "ScheduleNewWorkorder": "Nouveau bon de travail de service", + "SchedulePrintWorkorders": "Print selected work orders", + "ScheduleSelectScheduleableUserGroup": "Sélectionner utilisateurs programmables", + "ScheduleShowClosed": "Display Open / Closed (and open) work orders", + "ScheduleTimeLineView": "Display single day view as time line / regular", + "ScheduleToday": "Aujourd'hui", + "ScheduleWeekView": "Affichage Semaine de 7 jours", + "ScheduleWorkWeekView": "Affichage Semaine de 5 jours", + "ScheduleToolBar": "Barre d'outils Programmation", + "SecurityGroupFormSetAll": "Régler tous les niveaux de sécurité sur le niveau sélectionné", + "WorkorderFormSetAllPartsUsedInService": "Définir toutes les pièces comme utilisées", + "UIGridLayoutDescription": "Description de présentation de grille", + "UIGridLayoutGridKey": "Clé de grille", + "UIGridLayoutLayoutContent": "Données de présentation de grille", + "UIGridLayoutLayoutSize": "Taille des données de présentation de grille", + "UIGridLayoutObjectName": "Objet de présentation de grille IU", + "UnitBoughtHere": "Acheté ici", + "UnitCustom0": "Champ personnalisé 0", + "UnitCustom1": "Champ personnalisé 1", + "UnitCustom2": "Champ personnalisé 2", + "UnitCustom3": "Champ personnalisé 3", + "UnitCustom4": "Champ personnalisé 4", + "UnitCustom5": "Champ personnalisé 5", + "UnitCustom6": "Champ personnalisé 6", + "UnitCustom7": "Champ personnalisé 7", + "UnitCustom8": "Champ personnalisé 8", + "UnitCustom9": "Champ personnalisé 9", + "UnitDescription": "Description", + "UnitLastMeter": "Dernière lecture de compteur", + "UnitLifeSpan": "Durée de vie", + "UnitList": "Unités", + "UnitMetered": "Unité mesurée", + "UnitNotes": "Notes", + "UnitOverrideLength": "Ignorer la durée", + "UnitOverrideLifeTime": "Ignorer la garantie à vie", + "UnitOverrideWarranty": "Ignorer la garantie", + "UnitOverrideWarrantyExpiryDate": "Date d'expiration de garantie ignorée", + "UnitOverrideWarrantyTerms": "Ignorer les conditions de la garantie", + "UnitParentUnitID": "Unité parent de cette unité", + "UnitPurchasedDate": "Date d'achat", + "UnitPurchaseFromID": "Acheté chez", + "UnitReceipt": "Numéro de reçu", + "UnitReplacedByUnitID": "Remplacé par unité", + "UnitSerial": "Numéro de série", + "UnitText1": "Text1", + "UnitText2": "Text2", + "UnitText3": "Text3", + "UnitText4": "Text4", + "UnitUINotWarrantiedDisplay": "Unité sans garantie", + "UnitUIWarrantiedDisplay": "Unité sous garantie jusqu'au {0}\r\n\r\nConditions de garantie : =-=-=-=-=-=-=-=- {1}", + "UnitUIWarrantyExpiredDisplay": "La garantie de l'unité a expiré {0}", + "UnitUnitHasOwnAddress": "L'unité possède sa propre adresse", + "UnitWorkorderLastServicedID": "Dernière date de service du bon de travail", + "UnitMeterReadingDescription": "Description de compteur", + "UnitMeterReadingList": "Liste de lectures de compteur d'unités", + "UnitMeterReadingMeter": "Lecture de compteur d'unités", + "UnitMeterReadingMeterDate": "Date de lecture de compteur", + "UnitMeterReadingWorkorderItemID": "Lecture de compteur sur bon de travail", + "UnitModelCustom0": "Champ personnalisé 0", + "UnitModelCustom1": "Champ personnalisé 1", + "UnitModelCustom2": "Champ personnalisé 2", + "UnitModelCustom3": "Champ personnalisé 3", + "UnitModelCustom4": "Champ personnalisé 4", + "UnitModelCustom5": "Champ personnalisé 5", + "UnitModelCustom6": "Champ personnalisé 6", + "UnitModelCustom7": "Champ personnalisé 7", + "UnitModelCustom8": "Champ personnalisé 8", + "UnitModelCustom9": "Champ personnalisé 9", + "UnitModelDiscontinued": "Abandonné", + "UnitModelDiscontinuedDate": "Date d'abandon", + "UnitModelIntroducedDate": "Date d'introduction", + "UnitModelLifeTimeWarranty": "Garantie à vie", + "UnitModelList": "Modèles d'unité", + "UnitModelModelNumber": "Numéro de modèle", + "UnitModelName": "Nom de modèle d'unité", + "UnitModelNotes": "Notes", + "UnitModelUPC": "CUP", + "UnitModelVendorID": "Unit model vendor", + "UnitModelWarrantyLength": "Durée de garantie", + "UnitModelWarrantyTerms": "Conditions de garantie", + "UnitModelCategoryDescription": "Description", + "UnitModelCategoryList": "Catégories de modèles d'unité", + "UnitModelCategoryName": "Nom de catégorie de modèles d'unité", + "UnitNameDisplayFormatsModelModelNumberSerial": "Model name, model number, serial number", + "UnitNameDisplayFormatsModelNumberModelSerial": "Model number, model name, serial number", + "UnitNameDisplayFormatsModelSerial": "Numéro de modèle, numéro de série", + "UnitNameDisplayFormatsSerialDescription": "Serial number, description", + "UnitNameDisplayFormatsSerialModel": "Numéro de série, numéro de modèle", + "UnitNameDisplayFormatsSerialModelVendor": "Numéro de série, numéro de modèle, fournisseur", + "UnitNameDisplayFormatsSerialOnly": "Numéro de série", + "UnitNameDisplayFormatsVendorModelModelNumberSerial": "Vendor, model name, model number, serial number", + "UnitNameDisplayFormatsVendorModelSerial": "Fournisseur, numéro de modèle, numéro de série", + "UnitNameDisplayFormatsVendorSerial": "Fournisseur - numéro de série", + "UnitNameDisplayFormatsVendorSerialDescription": "Vendor, serial number, description", + "UnitOfMeasureList": "Unités de mesure", + "UnitOfMeasureName": "Nom d'unité de mesure", + "UnitServiceTypeDescription": "Description", + "UnitServiceTypeList": "Types de service d'unité", + "UnitServiceTypeName": "Nom", + "UserCustom0": "Champ personnalisé 0", + "UserCustom1": "Champ personnalisé 1", + "UserCustom2": "Champ personnalisé 2", + "UserCustom3": "Champ personnalisé 3", + "UserCustom4": "Champ personnalisé 4", + "UserCustom5": "Champ personnalisé 5", + "UserCustom6": "Champ personnalisé 6", + "UserCustom7": "Champ personnalisé 7", + "UserCustom8": "Champ personnalisé 8", + "UserCustom9": "Champ personnalisé 9", + "UserDefaultWarehouseID": "Magasin par défaut", + "UserEmailAddress": "Adresse e-mail d'utilisateur", + "UserEmployeeNumber": "Numéro d'employé", + "UserErrorNotSelectable": "L'utilisateur sélectionné n'est pas activé ou n'est pas un utilisateur programmable", + "UserEventQuickNotification": "Quick Notification", + "UserFirstName": "Prénom", + "UserInitials": "Initiales", + "UserLastName": "Nom", + "UserList": "Utilisateurs", + "UserLogin": "Nom d'utilisateur", + "UserMemberOfGroup": "Groupe de sécurité", + "UserMustBeActive": "This user must be active as it has open schedule items", + "UserMustBeScheduleable": "This user must be a Scheduleable User type to preserve data history", + "UserNotes": "Notes", + "UserPageAddress": "Adresse messageur", + "UserPageMaxText": "Texte max. messageur", + "UserPassword": "Mot de passe", + "UserPhone1": "Téléphone1", + "UserPhone2": "Téléphone 2", + "UserScheduleBackColor": "Couleur d'arrière-plan de programmation", + "UserStatus": "Status", + "UserSubContractor": "Est un sous-traitant", + "UserTimeZoneOffset": "Override timezone", + "UserUIClearAllLayoutCustomization": "Effacer tous les formulaires personnalisés d'utilisateur", + "UserUserCertifications": "Certificats", + "UserUserSkills": "Aptitudes", + "UserUserType": "Type d'utilisateur", + "UserVendorID": "Fournisseur sous-traitant", + "UserCertificationDescription": "Description", + "UserCertificationList": "Certifications d'utilisateur", + "UserCertificationName": "Nom de certification d'utilisateur", + "UserCertificationAssignedValidStartDate": "Date de début valide", + "UserCertificationAssignedValidStopDate": "Date de fin valide", + "UserRightList": "Autorisations de membre", + "UserRightRight": "Objet interne", + "UserRightSecurityLevel": "Niveau de sécurité", + "UserSkillDescription": "Description", + "UserSkillList": "Aptitudes d'utilisateur", + "UserSkillName": "Nom d'aptitude d'utilisateur", + "UserTypesAdministrator": "Utilisateur administrateur", + "UserTypesClient": "Utilisateur client", + "UserTypesHeadOffice": "Utilisateur client de siège social", + "UserTypesNonSchedulable": "Utilisateur non programmable", + "UserTypesSchedulable": "Utilisateur programmable", + "UserTypesUtilityNotification": "Compte de serveur de notifications", + "VendorAccountNumber": "Numéro de compte", + "VendorContact": "Contact", + "VendorContactNotes": "Other contacts", + "VendorCustom0": "Champ personnalisé 0", + "VendorCustom1": "Champ personnalisé 1", + "VendorCustom2": "Champ personnalisé 2", + "VendorCustom3": "Champ personnalisé 3", + "VendorCustom4": "Champ personnalisé 4", + "VendorCustom5": "Champ personnalisé 5", + "VendorCustom6": "Champ personnalisé 6", + "VendorCustom7": "Champ personnalisé 7", + "VendorCustom8": "Champ personnalisé 8", + "VendorCustom9": "Champ personnalisé 9", + "VendorEmail": "Email", + "VendorList": "Fournisseurs", + "VendorName": "Nom de fournisseur", + "VendorNotes": "Notes", + "VendorPhone1": "Business", + "VendorPhone2": "Fax", + "VendorPhone3": "Home", + "VendorPhone4": "Mobile", + "VendorPhone5": "Pager", + "VendorVendorType": "Type de fournisseur", + "VendorVendorTypeManufacturer": "Fabricant", + "VendorVendorTypeShipper": "Expéditionnaire", + "VendorVendorTypeSubContractor": "Sous-traitant", + "VendorVendorTypeThirdPartyRepair": "Réparation effectuée par un tiers", + "VendorVendorTypeWholesaler": "Grossiste", + "WikiPageInternalOnly": "Internal users only", + "WikiPageTitle": "Title", + "WorkorderClosed": "Fermé", + "WorkorderConvertScheduledUserToLabor": "Copier Utilisateur programmé dans Main d'oeuvre", + "WorkorderCopyWorkorderItem": "Copier l'élément de bon de travail sélectionné dans un bon de travail existant pour ce client", + "WorkorderMoveWorkorderItem": "Placer l'élément de bon de travail sur un autre bon de travail", + "WorkorderCustomerContactName": "Contact", + "WorkorderCustomerReferenceNumber": "Référence client n° ", + "WorkorderDeleted": "Supprimé", + "WorkorderClosedIsPermanent": "L'état d'un bon de travail fermé ne peut plus être défini comme ouvert", + "WorkorderDeleteLastWorkorderItem": "Les bons de travail doivent toujours contenir au moins un élément", + "WorkorderDirtyOrBrokenRules": "Impossible de terminer cette opération : le bon de travail n'est pas enregistré ou enfreint certaines règles", + "WorkorderLoanItemsNotReturned": "Impossible de terminer cette opération : un ou plusieurs éléments prêtés n'ont pas encore été rendus", + "WorkorderNotCloseableDueToErrors": "Impossible de fermer ce bon de travail car une ou plusieurs règles n'ont pas été respectées", + "WorkorderNotCompleteableDueToErrors": "Impossible de considérer ce service comme terminé car une ou plusieurs règles n'ont pas été respectées", + "WorkorderPartRequestsOnOrder": "Impossible de terminer cette opération : une ou plusieurs demandes de pièces n'ont pas encore été réceptionnées", + "WorkorderPartRequestsUnOrdered": "This operation can not be completed - One or more unordered part requests need to be removed first", + "WorkorderSourceInvalidType": "Type de bon de travail source non valide", + "WorkorderEventCloseByDatePassed": "Bon de travail - “Date de fermeture” dépassée", + "WorkorderEventQuoteUpdated": "Quote - created / updated", + "WorkorderEventStatus": "Bon de travail - “État” modifié", + "WorkorderFormLayoutID": "ID de présentation de formulaire", + "WorkorderFromPMID": "Parent de l'E.P.", + "WorkorderFromQuoteID": "Parent du devis", + "WorkorderGenerateUnit": "Generate unit from selected part", + "WorkorderInternalReferenceNumber": "Référence interne n° ", + "WorkorderListAll": "List all work orders", + "WorkorderOnsite": "Sur site", + "WorkorderServiceCompleted": "Service terminé", + "WorkorderSign": "Sign", + "WorkorderSummary": "Résumé", + "WorkorderTemplate": "Modèle", + "WorkorderTemplateDescription": "Template description", + "WorkorderTemplateFreshPrice": "Use current Part prices on generated order", + "WorkorderTemplateID": "ID modèle", + "WorkorderWarningClosedChanged": "Attention : si vous continuez, le bon de travail sera définitivement fermé \r\n\r\nÊtes-vous sûr ?", + "WorkorderWarningNotAllPartsUsed": "Attention : une ou plusieurs pièces de ce bon de travail ne sont pas signalées comme UTILISÉES\r\n\r\nSi vous continuez, toutes les pièces seront automatiquement signalées comme UTILISÉES\r\n\r\nÊtes-vous sûr ?", + "WorkorderWarningServiceCompletedChanged": "Attention : ce formulaire se refermera si vous continuez et l'état terminé du service de ce bon de travail sera modifié\r\n \r\nÊtes-vous sûr ?", + "WorkorderWorkorderItems": "Éléments de bon de travail", + "WorkorderCategoryDescription": "Description", + "WorkorderCategoryList": "Catégories de bons de travail", + "WorkorderCategoryName": "Nom de catégorie de bons de travail", + "WorkorderDetailsList": "Détails du bon de travail", + "WorkorderItemCustom0": "Champ personnalisé 0", + "WorkorderItemCustom1": "Champ personnalisé 1", + "WorkorderItemCustom2": "Champ personnalisé 2", + "WorkorderItemCustom3": "Champ personnalisé 3", + "WorkorderItemCustom4": "Champ personnalisé 4", + "WorkorderItemCustom5": "Champ personnalisé 5", + "WorkorderItemCustom6": "Champ personnalisé 6", + "WorkorderItemCustom7": "Champ personnalisé 7", + "WorkorderItemCustom8": "Champ personnalisé 8", + "WorkorderItemCustom9": "Champ personnalisé 9", + "WorkorderItemCustomFields": "Champs personnalisés", + "WorkorderItemEventNotServiced": "Élément de bon de travail - pas traité assez vite", + "WorkorderItemExpenses": "Dépenses", + "WorkorderItemLabors": "Main d'oeuvre", + "WorkorderItemList": "Éléments", + "WorkorderItemLoans": "Prêts", + "WorkorderItemOutsideService": "Service extérieur", + "WorkorderItemPartRequests": "Demandes de pièces", + "WorkorderItemParts": "Pièces", + "WorkorderItemPriorityID": "Priorité", + "WorkorderItemRequestDate": "Date de demande", + "WorkorderItemScheduledUsers": "Utilisateurs programmés", + "WorkorderItemSummary": "Résumé d'élément", + "WorkorderItemTaskListID": "Liste de tâches", + "WorkorderItemTasks": "Tâches", + "WorkorderItemTechNotes": "Notes de service", + "WorkorderItemTravels": "Déplacement", + "WorkorderItemTypeID": "Type d'élément de bon de travail", + "WorkorderItemWarrantyService": "Service de garantie", + "WorkorderItemWorkorderStatusID": "État d'élément de bon de travail", + "WorkorderItemLaborLaborBanked": "Déduit des sommes prépayées", + "WorkorderItemLaborLaborRateCharge": "Facturation au tarif", + "WorkorderItemLaborList": "Éléments de main d'oeuvre", + "WorkorderItemLaborNoChargeQuantity": "Quantité non facturée", + "WorkorderItemLaborServiceDetails": "Détails de service", + "WorkorderItemLaborServiceRateID": "Tarif de service", + "WorkorderItemLaborServiceRateQuantity": "Quantité de tarif de service", + "WorkorderItemLaborServiceStartDate": "Date et heure de début de service", + "WorkorderItemLaborServiceStopDate": "Date et heure de fin de service", + "WorkorderItemLaborTaxCodeID": "Code de taxe", + "WorkorderItemLaborTaxRateSaleID": "Taxe sur les ventes", + "WorkorderItemLaborUIBankWarning": "Souhaitez-vous réellement déduire cet enregistrement des sommes prépayées ? (L'enregistrement sera bloqué et il ne sera plus possible de le modifier)", + "WorkorderItemLaborUIReBankWarning": "Cet élément est déjà déduit des sommes prépayées", + "WorkorderItemLaborUserID": "Utilisateur", + "WorkorderItemLoanCharges": "Frais", + "WorkorderItemLoanDueDate": "Date de retour", + "WorkorderItemLoanList": "Éléments de prêt", + "WorkorderItemLoanLoanItem": "Élément de prêt", + "WorkorderItemLoanLoanItemID": "Élément de prêt", + "WorkorderItemLoanLoanTaxA": "Taxe A", + "WorkorderItemLoanLoanTaxAExempt": "Exemption de taxe A", + "WorkorderItemLoanLoanTaxB": "Taxe B", + "WorkorderItemLoanLoanTaxBExempt": "Exemption de taxe B", + "WorkorderItemLoanLoanTaxOnTax": "Taxe sur taxe", + "WorkorderItemLoanLoanTaxRateSale": "Taxe", + "WorkorderItemLoanNotes": "Notes", + "WorkorderItemLoanOutDate": "Prêté", + "WorkorderItemLoanQuantity": "Rate quantity", + "WorkorderItemLoanRate": "Rate", + "WorkorderItemLoanRateAmount": "Rate amount", + "WorkorderItemLoanReturnDate": "Rendu", + "WorkorderItemLoanTaxCodeID": "Taxe sur les ventes", + "WorkorderItemMiscExpenseChargeAmount": "Quantité à facturer", + "WorkorderItemMiscExpenseChargeTaxCodeID": "Code de taxe à facturer", + "WorkorderItemMiscExpenseChargeToClient": "Facturer au client ?", + "WorkorderItemMiscExpenseDescription": "Description", + "WorkorderItemMiscExpenseTaxA": "Valeur taxe A Dépenses diverses", + "WorkorderItemMiscExpenseTaxAExempt": "Exemption de taxe A Dépenses diverses", + "WorkorderItemMiscExpenseTaxB": "Valeur taxe B Dépenses diverses", + "WorkorderItemMiscExpenseTaxBExempt": "Exemption de taxe B Dépenses diverses", + "WorkorderItemMiscExpenseTaxOnTax": "Taxe sur taxe Dépenses diverses", + "WorkorderItemMiscExpenseTaxRateSale": "Tarif taxe Dépenses diverses", + "WorkorderItemMiscExpenseList": "Éléments de dépenses diverses", + "WorkorderItemMiscExpenseName": "Résumé dépenses diverses", + "WorkorderItemMiscExpenseReimburseUser": "Rembourser l'utilisateur ?", + "WorkorderItemMiscExpenseTaxPaid": "Taxe payée", + "WorkorderItemMiscExpenseTotalCost": "Coût total", + "WorkorderItemMiscExpenseUser": "Utilisateur", + "WorkorderItemMiscExpenseUserID": "Utilisateur", + "WorkorderItemOutsideServiceDateETA": "Date probable d'arrivée", + "WorkorderItemOutsideServiceDateReturned": "Date de retour", + "WorkorderItemOutsideServiceDateSent": "Date d'envoi", + "WorkorderItemOutsideServiceEventUnitBackFromService": "Service extérieur d'élément de bon de travail - unité récupérée", + "WorkorderItemOutsideServiceEventUnitNotBackFromServiceByETA": "Service extérieur d'élément de bon de travail - unité en retard", + "WorkorderItemOutsideServiceNotes": "Notes", + "WorkorderItemOutsideServiceReceivedBack": "Récupéré", + "WorkorderItemOutsideServiceRepairCost": "Coût de réparation", + "WorkorderItemOutsideServiceRepairPrice": "Prix de réparation", + "WorkorderItemOutsideServiceRMANumber": "Numéro RMA", + "WorkorderItemOutsideServiceSenderUserID": "Expédié par", + "WorkorderItemOutsideServiceShippingCost": "Coût d'expédition", + "WorkorderItemOutsideServiceShippingPrice": "Prix d'expédition", + "WorkorderItemOutsideServiceTrackingNumber": "Numéro de suivi", + "WorkorderItemOutsideServiceVendorSentToID": "Envoyé à", + "WorkorderItemOutsideServiceVendorSentViaID": "Envoyé via", + "WorkorderItemPartDescription": "Description", + "WorkorderItemPartDiscount": "Remise", + "WorkorderItemPartDiscountType": "Type de remise", + "WorkorderItemPartHasAffectedInventory": "A affecté le stock", + "WorkorderItemPartList": "Éléments de pièce", + "WorkorderItemPartPartID": "Pièce", + "WorkorderItemPartPartSerialID": "Numéro de série", + "WorkorderItemPartPartWarehouseID": "Magasin", + "WorkorderItemPartPrice": "Prix", + "WorkorderItemPartQuantity": "Quantité", + "WorkorderItemPartQuantityReserved": "Quantité présélectionnée", + "WorkorderItemPartTaxPartSaleID": "Taxe sur les ventes", + "WorkorderItemPartUIQuantityReservedPM": "Quantité requise", + "WorkorderItemPartUIQuantityReservedQuote": "Quantité estimée", + "WorkorderItemPartUsed": "Utilisé pour le service", + "WorkorderItemPartWarningInsufficientStock": "Stock insuffisant ({0:N}). Voulez-vous effectuer une commande {1:N} ?", + "WorkorderItemPartWarningPartNotFound": "Pièce introuvable dans la liste des pièces", + "WorkorderItemPartRequestNotDeleteableOnOrder": "Impossible de supprimer une demande de pièce d'élément de bon de travail si aucune pièce n'est en commande. La demande pourra être supprimée après réception des pièces.", + "WorkorderItemPartRequestEventPartsReceived": "Demande de pièce d'élément de bon de travail - pièces réceptionnées", + "WorkorderItemPartRequestList": "Demandes de pièces", + "WorkorderItemPartRequestOnOrder": "En commande", + "WorkorderItemPartRequestPartID": "Pièce", + "WorkorderItemPartRequestPartWarehouseID": "Magasin", + "WorkorderItemPartRequestQuantity": "Quantité", + "WorkorderItemPartRequestReceived": "Réceptionné", + "WorkorderItemScheduledUserRecordIncomplete": "Rien à programmer", + "WorkorderItemScheduledUserEstimatedQuantity": "Quantité estimée", + "WorkorderItemScheduledUserEventCreatedUpdated": "Utilisateur programmé d'élément de bon de travail - (créé / actualisé)", + "WorkorderItemScheduledUserEventPendingAlert": "Utilisateur programmé d'élément de bon de travail - Événement imminent", + "WorkorderItemScheduledUserList": "Éléments d'utilisateur programmé", + "WorkorderItemScheduledUserServiceRateID": "Tarif conseillé", + "WorkorderItemScheduledUserStartDate": "Date et heure de début", + "WorkorderItemScheduledUserStartDateRelative": "Début (relatif)", + "WorkorderItemScheduledUserStopDate": "Date et heure de fin", + "WorkorderItemScheduledUserUserID": "Utilisateur", + "WorkorderItemScheduledUserWarnOutOfRegion": "Warning: User is not in client's region - won't see this item", + "WorkorderItemTaskCompletionTypeComplete": "Terminé", + "WorkorderItemTaskCompletionTypeIncomplete": "À faire", + "WorkorderItemTaskCompletionTypeNotApplicable": "N/D", + "WorkorderItemTaskObject": "Tâche d'élément de bon de travail", + "WorkorderItemTaskTaskID": "Tâche", + "WorkorderItemTaskWorkorderItemTaskCompletionType": "État", + "WorkorderItemTravelDistance": "Distance", + "WorkorderItemTravelList": "Éléments de déplacement", + "WorkorderItemTravelNoChargeQuantity": "Quantité non facturée", + "WorkorderItemTravelNotes": "Notes", + "WorkorderItemTravelServiceRateID": "Tarif de déplacement", + "WorkorderItemTravelTaxCodeID": "Code de taxe", + "WorkorderItemTravelTaxRateSaleID": "Taxe sur les ventes", + "WorkorderItemTravelDetails": "Détails du déplacement", + "WorkorderItemTravelRateCharge": "Facturation au tarif de déplacement", + "WorkorderItemTravelRateID": "Tarif de déplacement", + "WorkorderItemTravelRateQuantity": "Quantité", + "WorkorderItemTravelStartDate": "Date de début", + "WorkorderItemTravelStopDate": "Date de fin", + "WorkorderItemTravelUserID": "Utilisateur", + "WorkorderItemTypeDescription": "Description", + "WorkorderItemTypeList": "Types d'élément de bon de travail", + "WorkorderItemTypeName": "Nom de type d'élément de bon de travail", + "WorkorderPreventiveMaintenanceDayOfTheWeek": "Jour de semaine souhaité", + "WorkorderPreventiveMaintenanceGenerateServiceWorkorder": "Bon de travail de service généré manuellement", + "WorkorderPreventiveMaintenanceGenerateSpan": "Générer une période de temps", + "WorkorderPreventiveMaintenanceGenerateSpanUnit": "Générer", + "WorkorderPreventiveMaintenanceList": "Entretien préventif", + "WorkorderPreventiveMaintenanceNextServiceDate": "Date de service suivant", + "WorkorderPreventiveMaintenanceStopGeneratingDate": "Arrêter de générer des dates", + "WorkorderPreventiveMaintenanceThresholdSpan": "Période seuil", + "WorkorderPreventiveMaintenanceThresholdSpanUnit": "Seuil", + "WorkorderPreventiveMaintenanceByUnitList": "Entretien préventif par unité", + "WorkorderQuoteDateApproved": "Approuvé", + "WorkorderQuoteDateSubmitted": "Envoyé", + "WorkorderQuoteGenerateServiceWorkorder": "Générer un bon de travail de service à partir de ce devis", + "WorkorderQuoteIntroduction": "Texte d'introduction", + "WorkorderQuoteList": "Devis", + "WorkorderQuotePreparedByID": "Préparé par l'utilisateur", + "WorkorderQuoteQuoteNumber": "Numéro de devis", + "WorkorderQuoteQuoteRequestDate": "Demandé", + "WorkorderQuoteQuoteStatusType": "État", + "WorkorderQuoteServiceWorkorderID": "Bon de travail de service", + "WorkorderQuoteValidUntilDate": "Date de validité", + "WorkorderQuoteStatusTypesAwarded": "Attribué", + "WorkorderQuoteStatusTypesInProgress": "En cours", + "WorkorderQuoteStatusTypesNew": "New", + "WorkorderQuoteStatusTypesNotAwarded": "Non attribué", + "WorkorderQuoteStatusTypesNotAwarded2": "Beyond economical repair", + "WorkorderQuoteStatusTypesSubmitted": "Envoyé, en attente", + "WorkorderServiceAge": "Age", + "WorkorderServiceClientRequestID": "Référence de demande de client", + "WorkorderServiceCloseByDate": "Date de fermeture", + "WorkorderServiceInvoiceNumber": "Numéro de facture", + "WorkorderServiceList": "Bons de travail de service", + "WorkorderServiceQuoteWorkorderID": "Devis", + "WorkorderServiceServiceDate": "Date de service", + "WorkorderServiceServiceDateRelative": "Date de service (relative)", + "WorkorderServiceServiceNumber": "Numéro de service", + "WorkorderServiceWorkorderPreventiveMaintenanceWorkorderID": "Entretien préventif", + "WorkorderStatusARGB": "Couleur ARVB", + "WorkorderStatusBold": "Gras", + "WorkorderStatusCompletedStatus": "Cet état est “Terminé”", + "WorkorderStatusList": "États de bon de travail", + "WorkorderStatusName": "Nom d'état de bon de travail", + "WorkorderStatusUnderlined": "Souligné", + "WorkorderSummaryTemplate": "Modèle de résumé d'élément de bon de travail", + "WorkorderSummaryWorkorderItem": "Infos de bon de travail à afficher" +} \ No newline at end of file diff --git a/server/AyaNova/util/ApplicationLogging.cs b/server/AyaNova/util/ApplicationLogging.cs new file mode 100644 index 00000000..d37c0f14 --- /dev/null +++ b/server/AyaNova/util/ApplicationLogging.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.Logging; + + +namespace AyaNova.Util +{ + /// + /// Shared logger + /// + internal static class ApplicationLogging + { + internal static ILoggerFactory LoggerFactory { get; set; }// = new LoggerFactory(); + internal static ILogger CreateLogger() => LoggerFactory.CreateLogger(); + internal static ILogger CreateLogger(string categoryName) => LoggerFactory.CreateLogger(categoryName); + + } +} \ No newline at end of file diff --git a/server/AyaNova/util/AySchema.cs b/server/AyaNova/util/AySchema.cs new file mode 100644 index 00000000..2c79bf34 --- /dev/null +++ b/server/AyaNova/util/AySchema.cs @@ -0,0 +1,286 @@ +using System; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using AyaNova.Models; + +namespace AyaNova.Util +{ + + //Key generator controller + public static class AySchema + { + private static ILogger log; + private static AyContext ct; + + + + + ///////////////////////////////////////////////////////////////// + /////////// CHANGE THIS ON NEW SCHEMA UPDATE //////////////////// + + private const int DESIRED_SCHEMA_LEVEL = 8; + + internal const long EXPECTED_COLUMN_COUNT = 69; + internal const long EXPECTED_INDEX_COUNT = 14; + + ///////////////////////////////////////////////////////////////// + + + static int startingSchema = -1; + public static int currentSchema = -1; + + + + //check and update schema + public static void CheckAndUpdate(AyContext context, ILogger logger) + { + ct = context; + log = logger; + + //Check if ayschemaversion table exists + bool aySchemaVersionExists = false; + + using (var command = ct.Database.GetDbConnection().CreateCommand()) + { + command.CommandText = "SELECT * FROM information_schema.tables WHERE table_name = 'aschemaversion'"; + ct.Database.OpenConnection(); + using (var result = command.ExecuteReader()) + { + if (result.HasRows) + { + aySchemaVersionExists = true; + } + ct.Database.CloseConnection(); + } + } + + + //Create schema table (v1) + if (!aySchemaVersionExists) + { + log.LogDebug("aschemaversion table not found, creating now"); + //nope, no schema table, add it now and set to v1 + using (var cm = ct.Database.GetDbConnection().CreateCommand()) + { + ct.Database.OpenConnection(); + cm.CommandText = "CREATE TABLE aschemaversion (schema INTEGER NOT NULL);"; + cm.ExecuteNonQuery(); + + cm.CommandText = "insert into aschemaversion (schema) values (1);"; + cm.ExecuteNonQuery(); + + ct.Database.CloseConnection(); + startingSchema = 1; + currentSchema = 1; + } + } + else + { + //get current schema level + using (var cm = ct.Database.GetDbConnection().CreateCommand()) + { + log.LogDebug("Fetching current schema version"); + cm.CommandText = "SELECT schema FROM aschemaversion;"; + ct.Database.OpenConnection(); + using (var result = cm.ExecuteReader()) + { + if (result.HasRows) + { + result.Read(); + currentSchema = startingSchema = result.GetInt32(0); + ct.Database.CloseConnection(); + log.LogDebug("AyaNova schema version is " + currentSchema.ToString()); + } + else + { + ct.Database.CloseConnection(); + throw new System.Exception("AyaNova->AySchema->CheckAndUpdate: Error reading schema version"); + } + } + } + } + + //Bail early no update? + if (currentSchema == DESIRED_SCHEMA_LEVEL) + { + log.LogDebug("Current schema is at required schema version " + currentSchema.ToString()); + return; + } + + log.LogInformation("AyaNova database needs to be updated from schema version {0} to version {1}", currentSchema, DESIRED_SCHEMA_LEVEL); + + //************* SCHEMA UPDATES ****************** + + ////////////////////////////////////////////////// + // USER table locale text and default data + if (currentSchema < 2) + { + LogUpdateMessage(log); + + //create locale text tables + exec("CREATE TABLE alocale (id BIGSERIAL PRIMARY KEY, ownerid bigint not null, name varchar(255) not null, stock bool, created timestamp not null)"); + exec("CREATE UNIQUE INDEX localename_idx ON alocale (name)"); + exec("CREATE TABLE alocaleitem (id BIGSERIAL PRIMARY KEY, localeid bigint not null REFERENCES alocale (id), key text not null, display text not null)"); + exec("CREATE INDEX localeitemlid_key_idx ON alocaleitem (localeid,key)"); + + + //Prime the db with the default LOCALES + AyaNova.Biz.PrimeData.PrimeLocales(ct); + + //Add user table + exec("CREATE TABLE auser (id BIGSERIAL PRIMARY KEY, created timestamp not null, ownerid bigint not null, name varchar(255) not null, " + + "login text not null, password text not null, salt text not null, roles integer not null, localeid bigint REFERENCES alocale (id), " + + "dlkey text, dlkeyexpire timestamp)"); + + //Prime the db with the default MANAGER account + AyaNova.Biz.PrimeData.PrimeManagerAccount(ct); + + setSchemaLevel(++currentSchema); + } + + + ////////////////////////////////////////////////// + //LICENSE table + if (currentSchema < 3) + { + LogUpdateMessage(log); + + //Add user table + exec("CREATE TABLE alicense (id BIGSERIAL PRIMARY KEY, key text not null)"); + + setSchemaLevel(++currentSchema); + } + + + ////////////////////////////////////////////////// + //WIDGET table for development testing + if (currentSchema < 4) + { + LogUpdateMessage(log); + + //Add widget table + //id, text, longtext, boolean, currency, + exec("CREATE TABLE awidget (id BIGSERIAL PRIMARY KEY, ownerid bigint not null, name varchar(255) not null, created timestamp not null, " + + "startdate timestamp, enddate timestamp, dollaramount money, active bool, roles int4)"); + + setSchemaLevel(++currentSchema); + } + + + ////////////////////////////////////////////////// + // FileAttachment table + if (currentSchema < 5) + { + LogUpdateMessage(log); + + exec("CREATE TABLE afileattachment (id BIGSERIAL PRIMARY KEY, created timestamp not null, ownerid bigint not null," + + "attachtoobjectid bigint not null, attachtoobjecttype integer not null, " + + "storedfilename text not null, displayfilename text not null, contenttype text, notes text)"); + + //index required for ops that need to check if file already in db (delete, count refs etc) + exec("CREATE INDEX storedfilename_idx ON afileattachment (storedfilename);"); + + setSchemaLevel(++currentSchema); + } + + + ////////////////////////////////////////////////// + //TAG tables + if (currentSchema < 6) + { + LogUpdateMessage(log); + + exec("CREATE TABLE atag (id BIGSERIAL PRIMARY KEY, ownerid bigint not null, name varchar(35) not null, created timestamp not null)"); + exec("CREATE UNIQUE INDEX tagname_idx ON atag (name);"); + exec("CREATE TABLE atagmap (id BIGSERIAL PRIMARY KEY, created timestamp not null, ownerid bigint not null," + + "tagid bigint not null REFERENCES atag (id), tagtoobjectid bigint not null, tagtoobjecttype integer not null)"); + + setSchemaLevel(++currentSchema); + } + + + ////////////////////////////////////////////////// + // OPS LRO tables + if (currentSchema < 7) + { + LogUpdateMessage(log); + + exec("CREATE TABLE aopsjob (gid uuid PRIMARY KEY, ownerid bigint not null, name text not null, created timestamp not null, exclusive bool not null, " + + "startafter timestamp not null, jobtype integer not null, objectid bigint null, objecttype integer null, jobstatus integer not null, jobinfo text null)"); + exec("CREATE TABLE aopsjoblog (gid uuid PRIMARY KEY, jobid uuid not null REFERENCES aopsjob (gid), created timestamp not null, statustext text not null)"); + + setSchemaLevel(++currentSchema); + } + + + ////////////////////////////////////////////////// + //LICENSE table new columns + //TODO: DO I need this anymore??? + if (currentSchema < 8) + { + LogUpdateMessage(log); + + //Add license related stuff + exec("ALTER TABLE alicense ADD COLUMN dbid uuid"); + exec("ALTER TABLE alicense ADD COLUMN LastFetchStatus integer"); + exec("ALTER TABLE alicense ADD COLUMN LastFetchMessage text"); + + setSchemaLevel(++currentSchema); + } + + + + + + ////////////////////////////////////////////////// + // FUTURE + // if (currentSchema < 9) + // { + // LogUpdateMessage(log); + + // setSchemaLevel(++currentSchema); + // } + + + //!!!!WARNING: BE SURE TO UPDATE THE DbUtil::PrepareDatabaseForSeeding WHEN NEW TABLES ADDED!!!! + + + + log.LogInformation("Finished updating database schema to version {0}", currentSchema); + //************************************************************************************* + + + + }//eofunction + + + + private static void setSchemaLevel(int nCurrentSchema) + { + exec("UPDATE aschemaversion SET schema=" + nCurrentSchema.ToString()); + } + + //execute command query + private static void exec(string q) + { + using (var cm = ct.Database.GetDbConnection().CreateCommand()) + { + ct.Database.OpenConnection(); + cm.CommandText = q; + cm.ExecuteNonQuery(); + ct.Database.CloseConnection(); + } + } + + + private static void LogUpdateMessage(ILogger log) + { + log.LogDebug($"Updating database to schema version {currentSchema + 1}"); + } + + + //eoclass + } + //eons +} \ No newline at end of file diff --git a/server/AyaNova/util/AyaNovaVersion.cs b/server/AyaNova/util/AyaNovaVersion.cs new file mode 100644 index 00000000..568fb9d9 --- /dev/null +++ b/server/AyaNova/util/AyaNovaVersion.cs @@ -0,0 +1,29 @@ +namespace AyaNova.Util +{ + + + /// + /// Version strings centrally located for convenience + /// + internal static class AyaNovaVersion + { + public static string VersionString + { + get + { + return "8.0.0-alpha.2018.6.6"; + } + } + + public static string FullNameAndVersion + { + get + { + return "AyaNova server v" + VersionString; + } + } + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/CopyObject.cs b/server/AyaNova/util/CopyObject.cs new file mode 100644 index 00000000..ae72c8e4 --- /dev/null +++ b/server/AyaNova/util/CopyObject.cs @@ -0,0 +1,65 @@ +using System; +using System.Reflection; +using System.Linq; + +namespace AyaNova.Util +{ + + internal static class CopyObject + { + /// + /// Copies the data of one object to another. The target object 'pulls' properties of the first. + /// This any matching properties are written to the target. + /// + /// The object copy is a shallow copy only. Any nested types will be copied as + /// whole values rather than individual property assignments (ie. via assignment) + /// + /// The source object to copy from + /// The object to copy to + /// A comma delimited list of properties that should not be copied + /// Reflection binding access + public static void Copy(object source, object target, string excludedProperties="", BindingFlags memberAccess = BindingFlags.Public | BindingFlags.Instance) + { + string[] excluded = null; + if (!string.IsNullOrEmpty(excludedProperties)) + excluded = excludedProperties.Split(new char[1] { ',' }, StringSplitOptions.RemoveEmptyEntries); + + MemberInfo[] miT = target.GetType().GetMembers(memberAccess); + foreach (MemberInfo Field in miT) + { + string name = Field.Name; + + // Skip over any property exceptions + if (!string.IsNullOrEmpty(excludedProperties) && + excluded.Contains(name)) + continue; + + if (Field.MemberType == MemberTypes.Field) + { + FieldInfo SourceField = source.GetType().GetField(name); + if (SourceField == null) + continue; + + object SourceValue = SourceField.GetValue(source); + ((FieldInfo)Field).SetValue(target, SourceValue); + } + else if (Field.MemberType == MemberTypes.Property) + { + PropertyInfo piTarget = Field as PropertyInfo; + PropertyInfo SourceField = source.GetType().GetProperty(name, memberAccess); + if (SourceField == null) + continue; + + if (piTarget.CanWrite && SourceField.CanRead) + { + object SourceValue = SourceField.GetValue(source, null); + piTarget.SetValue(target, SourceValue, null); + } + } + } + } + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/DateUtil.cs b/server/AyaNova/util/DateUtil.cs new file mode 100644 index 00000000..324bc787 --- /dev/null +++ b/server/AyaNova/util/DateUtil.cs @@ -0,0 +1,69 @@ +using System; + +namespace AyaNova.Util +{ + + + internal static class DateUtil + { + /// + /// Is the current date after the referenced date by at least the duration specified + /// + /// UTC start point to compare to current UTC date + /// + /// + /// + /// + public static bool IsAfterDuration(DateTime startDate, int Hours, int Minutes = 0, int Seconds = 0) + { + TimeSpan ts = new TimeSpan(Hours, Minutes, Seconds); + return IsAfterDuration(startDate, ts); + } + + /// + /// Is the current date after the referenced date by at least the timespan specified + /// + /// UTC start point to compare to current UTC date + /// + /// + public static bool IsAfterDuration(DateTime startDate, TimeSpan tspan) + { + if (DateTime.UtcNow - startDate < tspan) + return false; + return true; + } + + + /// + /// An internally consistent empty or not relevant date marker: + /// January 1st 5555 + /// + /// + public static DateTime EmptyDateValue + { + get + { + return new DateTime(5555, 1, 1); + //Was going to use MaxValue but apparently that varies depending on culture + // and Postgres has issues with year 1 as it interprets as year 2001 + // so to be on safe side just defining one for all usage + } + } + + + /// + /// returns a UTC short date, short time formatted date for local display to end user in logs, errors etc at the server level + /// (Not related to UI display of dates and times) + /// + /// + /// + public static string ServerDateTimeString(DateTime DateToDisplay) + { + return DateToDisplay.ToLocalTime().ToString("g"); + } + + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/DbUtil.cs b/server/AyaNova/util/DbUtil.cs new file mode 100644 index 00000000..f9e3eabc --- /dev/null +++ b/server/AyaNova/util/DbUtil.cs @@ -0,0 +1,480 @@ +using System; +using Microsoft.Extensions.Logging; +using AyaNova.Models; +using System.Linq; +using System.Collections.Generic; + + +namespace AyaNova.Util +{ + + + internal static class DbUtil + { + private static string _RawAyaNovaConnectionString; + private static string _dbConnectionString; + private static string _dbName; + private static string _dbUserName; + private static string _dbPassword; + private static string _dbServer; + + #region parse connection string + internal static void ParseConnectionString(ILogger _log, string AyaNovaConnectionString) + { + + if (string.IsNullOrWhiteSpace(AyaNovaConnectionString)) + { + _log.LogDebug("There is no database server connection string set, AYANOVA_DB_CONNECTION is missing or empty. Will use default: \"Server=localhost;Username=postgres;Database=AyaNova;\""); + AyaNovaConnectionString = "Server=localhost;Username=postgres;Database=AyaNova;"; + } + + _RawAyaNovaConnectionString = AyaNovaConnectionString; + var builder = new System.Data.Common.DbConnectionStringBuilder(); + builder.ConnectionString = AyaNovaConnectionString; + + if (!builder.ContainsKey("database")) + { + _log.LogDebug("There is no database name specified (\"Database=\") in connection string. Will use default: \"Database=AyaNova;\""); + builder.Add("database", "AyaNova"); + } + + //Keep track of default values + _dbConnectionString = builder.ConnectionString; + if (builder.ContainsKey("database")) + _dbName = builder["database"].ToString(); + + if (builder.ContainsKey("username")) + _dbUserName = builder["username"].ToString(); + + if (builder.ContainsKey("password")) + _dbPassword = builder["password"].ToString(); + + if (builder.ContainsKey("server")) + _dbServer = builder["server"].ToString(); + + _log.LogDebug("AyaNova will use the following connection string: {0}", PasswordRedactedConnectionString(_dbConnectionString)); + + + } + + + /////////////////////////////////////////// + //clean out password from connection string + //for log purposes + private static string PasswordRedactedConnectionString(string cs) + { + var nStart = 0; + var nStop = 0; + var lwrcs = cs.ToLowerInvariant(); + nStart = lwrcs.IndexOf("password"); + if (nStart == -1) + { + //no password, just return it + return cs; + } + //find terminating semicolon + nStop = lwrcs.IndexOf(";", nStart); + if (nStop == -1 || nStop == lwrcs.Length) + { + //no terminating semicolon or that is the final character in the string + return cs.Substring(0, nStart + 9) + "[redacted];"; + } + else + { + //not the last thing in the string so return the whole string minus the password part + return cs.Substring(0, nStart + 9) + "[redacted];" + cs.Substring(nStop + 1); + } + } + #endregion + + #region Connection utilities + /////////////////////////////////////////// + //Verify that server exists + // + private static string AdminConnectionString + { + get + { + return _dbConnectionString.Replace(_dbName, "postgres"); + } + + } + + /////////////////////////////////////////// + //Connection string without password + // + internal static string DisplayableConnectionString + { + get + { + return PasswordRedactedConnectionString(_dbConnectionString); + } + + } + + + + #endregion + + #region DB verification + + /////////////////////////////////////////// + //Verify that server exists + // + internal static bool DatabaseServerExists(ILogger log, string logPrepend) + { + + try + { + //Try every 3 seconds for 10 tries before giving up + + var maxRetryAttempts = 10; + var pauseBetweenFailures = TimeSpan.FromSeconds(3); + RetryHelper.RetryOnException(maxRetryAttempts, pauseBetweenFailures, log, logPrepend + AdminConnectionString, () => + { + using (var conn = new Npgsql.NpgsqlConnection(AdminConnectionString)) + { + + conn.Open(); + conn.Close(); + } + }); + } + catch + { + return false; + } + return true; + + } + + + + + /////////////////////////////////////////// + //Verify that database exists, if not, then create it + // + internal static bool EnsureDatabaseExists(ILogger _log) + { + _log.LogDebug("Ensuring database exists. Connection string is: \"{0}\"", DisplayableConnectionString); + + using (var conn = new Npgsql.NpgsqlConnection(_dbConnectionString)) + { + try + { + conn.Open(); + conn.Close(); + } + catch (Exception e) + { + + //if it's a db doesn't exist that's ok, we'll create it, not an error + if (e is Npgsql.PostgresException) + { + if (((Npgsql.PostgresException)e).SqlState == "3D000") + { + //create the db here + using (var cnCreate = new Npgsql.NpgsqlConnection(AdminConnectionString)) + { + cnCreate.Open(); + + // Create the database desired + using (var cmd = new Npgsql.NpgsqlCommand()) + { + + cmd.Connection = cnCreate; + cmd.CommandText = "CREATE DATABASE \"" + _dbName + "\";"; + cmd.ExecuteNonQuery(); + _log.LogInformation("Database \"{0}\" created successfully!", _dbName); + } + cnCreate.Close(); + } + } + else + { + var err = string.Format("Database server connection failed. Connection string is: \"{0}\"", DisplayableConnectionString); + _log.LogCritical(e, "BOOT: E1000 - " + err); + err = err + "\nError reported was: " + e.Message; + throw new ApplicationException(err); + } + + } + + } + + } + return true; + + } + + + #endregion + + #region DB utilities + /////////////////////////////////////////// + // Drop and re-create db + // This is the NUCLEAR option and + // completely ditches the DB and all user uploaded files + // + internal static void DropAndRecreateDb(ILogger _log) + { + _log.LogInformation("Dropping and recreating Database \"{0}\"", _dbName); + + //clear all connections so that the database can be dropped + Npgsql.NpgsqlConnection.ClearAllPools(); + + using (var conn = new Npgsql.NpgsqlConnection(AdminConnectionString)) + { + conn.Open(); + + // Create the database desired + using (var cmd = new Npgsql.NpgsqlCommand()) + { + cmd.Connection = conn; + cmd.CommandText = "DROP DATABASE \"" + _dbName + "\";"; + cmd.ExecuteNonQuery(); + cmd.Connection = conn; + cmd.CommandText = "CREATE DATABASE \"" + _dbName + "\";"; + cmd.ExecuteNonQuery(); + _log.LogInformation("Database re-created successfully!"); + } + conn.Close(); + } + + //final cleanup step is to erase user uploaded files + FileUtil.EraseEntireContentsOfUserFilesFolder(); + } + + + ///////////////////////////////////////////////////////// + // Erase all user entered data from the db + // This is called by seeder for trial seeding purposes + // + internal static void PrepareDatabaseForSeeding(ILogger _log) + { + _log.LogInformation("Erasing Database \"{0}\"", _dbName); + AyaNova.Api.ControllerHelpers.ApiServerState apiServerState = (AyaNova.Api.ControllerHelpers.ApiServerState)ServiceProviderProvider.Provider.GetService(typeof(AyaNova.Api.ControllerHelpers.ApiServerState)); + + apiServerState.SetClosed("Erasing database"); + + //clear all connections so that the database can be dropped + Npgsql.NpgsqlConnection.ClearAllPools(); + + using (var conn = new Npgsql.NpgsqlConnection(_dbConnectionString)) + { + conn.Open(); + + using (var cmd = new Npgsql.NpgsqlCommand()) + { + cmd.Connection = conn; + cmd.CommandText = "delete from \"auser\" where id <> 1;"; + cmd.ExecuteNonQuery(); + } + + //THIS METHOD IS ONLY CALLED BY SEEDER + //SO ONLY REMOVE DATA THAT IS SEEDED + //I.E. Normal user business data, not infrastructure data like license or localized text etc + EraseTable("atagmap", conn); + EraseTable("atag", conn); + EraseTable("afileattachment", conn); + EraseTable("awidget", conn); + + conn.Close(); + } + + //If we got here then it's safe to erase the attachment files + FileUtil.EraseEntireContentsOfUserFilesFolder(); + + apiServerState.ResumePriorState(); + + } + + + /////////////////////////////////////////// + // Erase all data from the table specified + // + private static void EraseTable(string sTable, Npgsql.NpgsqlConnection conn) + { + using (var cmd = new Npgsql.NpgsqlCommand()) + { + cmd.Connection = conn; + cmd.CommandText = "TRUNCATE \"" + sTable + "\" RESTART IDENTITY CASCADE;"; + cmd.ExecuteNonQuery(); + } + } + + + /////////////////////////////////////////// + // Check if DB is empty + // + internal static bool DBIsEmpty(AyContext ctx, ILogger _log) + { + //TODO: This needs to be way more thorough, only the main tables though, no need to get crazy with it + //just stuff that would be shitty to have to re-enter + + _log.LogDebug("DB empty check"); + + //An empty db contains only one User + if (ctx.User.Count() > 1) return false; + + //No clients + //if(ctx.Client.Count()>0) return false; + + //No units + //if(ctx.Unit.Count()>0) return false; + + //No parts + //if(ctx.Part.Count()>0) return false; + + //No workorders + //if(ctx.Workorder.Count()>0) return false; + + + return true; + } + + + /////////////////////////////////////////// + // Ensure the db is not modified + // + internal static void CheckFingerPrint(long ExpectedColumns, long ExpectedIndexes, ILogger _log) + { + _log.LogDebug("Checking DB integrity"); + + long actualColumns = 0; + long actualIndexes = 0; + + using (var conn = new Npgsql.NpgsqlConnection(_dbConnectionString)) + { + conn.Open(); + + using (var command = conn.CreateCommand()) + { + //Count all columns in all our tables + command.CommandText = "SELECT count(*) FROM information_schema.columns where table_schema='public'"; + + using (var result = command.ExecuteReader()) + { + if (result.HasRows) + { + //check the values + result.Read(); + actualColumns = result.GetInt64(0); + } + else + { + var err = "E1030 - Database integrity check failed, could not obtain column data. Contact support."; + _log.LogCritical(err); + + throw new ApplicationException(err); + } + } + } + + using (var command = conn.CreateCommand()) + { + //Count all indexes in all our tables + command.CommandText = "select Count(*) from pg_indexes where schemaname='public'"; + + using (var result = command.ExecuteReader()) + { + if (result.HasRows) + { + //check the values + result.Read(); + actualIndexes = result.GetInt64(0); + } + else + { + var err = "E1030 - Database integrity check failed, could not obtain index data. Contact support."; + _log.LogCritical(err); + throw new ApplicationException(err); + } + } + } + conn.Close(); + + if (ExpectedColumns != actualColumns || ExpectedIndexes != actualIndexes) + { + var err = string.Format("E1030 - Database integrity check failed (C{0}I{1})", actualColumns, actualIndexes); + _log.LogCritical(err); + throw new ApplicationException(err); + } + } + } + + + /////////////////////////////////////////// + // Given a table name return the count of records in that table + // Used for metrics + // + /// + internal static long CountOfRecords(string TableName) + { + long ret = 0; + + using (var conn = new Npgsql.NpgsqlConnection(_dbConnectionString)) + { + conn.Open(); + + using (var command = conn.CreateCommand()) + { + command.CommandText = $"SELECT count(*) FROM {TableName}"; + using (var result = command.ExecuteReader()) + { + if (result.HasRows) + { + result.Read(); + ret = result.GetInt64(0); + } + } + } + conn.Close(); + } + return ret; + } + + + /////////////////////////////////////////// + // Returns all table names that are ours in current schema + // + /// + internal static List GetAllTablenames() + { + + List ret = new List(); + using (var conn = new Npgsql.NpgsqlConnection(_dbConnectionString)) + { + conn.Open(); + + using (var command = conn.CreateCommand()) + { + command.CommandText = "SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE';"; + using (var result = command.ExecuteReader()) + { + if (result.HasRows) + { + while (result.Read()) + { + ret.Add(result.GetString(0)); + } + } + } + } + conn.Close(); + } + return ret; + } + + + + + + + #endregion + + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/EnumAttributeExtension.cs b/server/AyaNova/util/EnumAttributeExtension.cs new file mode 100644 index 00000000..91fd0d2d --- /dev/null +++ b/server/AyaNova/util/EnumAttributeExtension.cs @@ -0,0 +1,36 @@ +using System; + +/// +/// Get custom attribute extension +/// +public static class EnumExtension +{ + /// + /// Check if enum has attribute type + /// Example usage bool c = Biz.AyaType.License.HasAttribute(typeof(Biz.AttachableAttribute)); + /// + /// + /// + /// + /// + public static bool HasAttribute(this Enum value, Type t) + { + var type = value.GetType(); + var name = Enum.GetName(type, value); + if (name != null) + { + var field = type.GetField(name); + if (field != null) + { + var attr = + Attribute.GetCustomAttribute(field, t); + if (attr != null) + { + return true; + } + } + } + + return false; + } +} \ No newline at end of file diff --git a/server/AyaNova/util/ExceptionUtil.cs b/server/AyaNova/util/ExceptionUtil.cs new file mode 100644 index 00000000..fe7406a8 --- /dev/null +++ b/server/AyaNova/util/ExceptionUtil.cs @@ -0,0 +1,32 @@ +using System; +using System.Text; + +namespace AyaNova.Util +{ + + + internal static class ExceptionUtil + { + + /// + /// Extract and return exception message + /// Handles innermost exceptions level by level + /// + /// + /// + public static string ExtractAllExceptionMessages(Exception ex) + { + StringBuilder sb = new StringBuilder(); + while (ex != null) + { + sb.AppendLine($"{ex.Source} -> {ex.Message}"); + ex = ex.InnerException; + } + + return sb.ToString(); + } + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/FileHash.cs b/server/AyaNova/util/FileHash.cs new file mode 100644 index 00000000..0f066967 --- /dev/null +++ b/server/AyaNova/util/FileHash.cs @@ -0,0 +1,25 @@ +using System.IO; +using System.Security.Cryptography; +using System; + +namespace AyaNova.Util +{ + + + internal static class FileHash + { + + internal static string GetChecksum(string filePath) + { + using (FileStream stream = File.OpenRead(filePath)) + { + SHA256Managed sha = new SHA256Managed(); + byte[] checksum = sha.ComputeHash(stream); + return BitConverter.ToString(checksum).Replace("-", String.Empty); + } + } + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/FileUtil.cs b/server/AyaNova/util/FileUtil.cs new file mode 100644 index 00000000..e417c507 --- /dev/null +++ b/server/AyaNova/util/FileUtil.cs @@ -0,0 +1,470 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; +using Newtonsoft.Json.Linq; +using AyaNova.Models; +using AyaNova.Biz; + + +using System.Linq; + +namespace AyaNova.Util +{ + /* + - Quickly generate large files in windows: http://tweaks.com/windows/62755/quickly-generate-large-test-files-in-windows/ + + */ + + internal static class FileUtil + { + + #region Folder ensurance + /// + /// Ensurs folders exist and are not identical + /// Throws an exception of they are found to be identical preventing startup + /// The reason for this is to prevent a future erase database operation (which erases all attachment files) + /// from erasing backups which might prevent recovery in case someone accidentally erases their database + /// + /// + /// + internal static void EnsureUserAndUtilityFoldersExistAndAreNotIdentical(string contentRootPath) + { + + //UserFiles + if (string.IsNullOrWhiteSpace(ServerBootConfig.AYANOVA_FOLDER_USER_FILES)) + { + ServerBootConfig.AYANOVA_FOLDER_USER_FILES = Path.Combine(contentRootPath, "userfiles"); + } + + //BackupFiles + if (ServerBootConfig.AYANOVA_FOLDER_BACKUP_FILES == null) + { + ServerBootConfig.AYANOVA_FOLDER_BACKUP_FILES = Path.Combine(contentRootPath, "backupfiles"); + } + + //Prevent using the same folder for both + if (string.Equals(Path.GetFullPath(ServerBootConfig.AYANOVA_FOLDER_USER_FILES), Path.GetFullPath(ServerBootConfig.AYANOVA_FOLDER_BACKUP_FILES), StringComparison.OrdinalIgnoreCase)) + { + throw new System.NotSupportedException("E1040: The configuration settings AYANOVA_FOLDER_USER_FILES and the AYANOVA_FOLDER_BACKUP_FILES must not point to the exact same location"); + } + + EnsurePath(ServerBootConfig.AYANOVA_FOLDER_USER_FILES); + EnsurePath(ServerBootConfig.AYANOVA_FOLDER_BACKUP_FILES); + } + + //create path if doesn't exist already + private static void EnsurePath(string path) + { + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + } + #endregion folder ensurance + + #region Utility file handling + + + /// + /// Get a path combining supplied file name and backup files folder + /// + /// + internal static string GetFullPathForUtilityFile(string fileName) + { + return Path.Combine(UtilityFilesFolder, fileName); + } + + + /// + /// Get backup folder + /// + /// + internal static string UtilityFilesFolder + { + get + { + return ServerBootConfig.AYANOVA_FOLDER_BACKUP_FILES; + } + } + + + /// + /// Delete a utility file (backup folder file) + /// + /// + internal static void DeleteUtilityFile(string fileName) + { + var utilityFilePath = GetFullPathForUtilityFile(fileName); + if (File.Exists(utilityFilePath)) + { + File.Delete(utilityFilePath); + } + } + + + /// + /// Get a list of files in the utility folder + /// + /// + /// search pattern for files desired, leave blank for any + /// + internal static List UtilityFileList(string searchPattern = "*") + { + List returnList = new List(); + foreach (string file in Directory.EnumerateFiles(UtilityFilesFolder, searchPattern)) + { + returnList.Add(Path.GetFileName(file)); + } + returnList.Sort(); + return returnList; + } + + + /// + /// Confirm if a file exists in the utility folder + /// + /// name of utility file + /// duh! + internal static bool UtilityFileExists(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + return false; + + var utilityFilePath = GetFullPathForUtilityFile(fileName); + return File.Exists(utilityFilePath); + + } + + #endregion Utility file handling + + #region Zip handling + //////////////////////////////////////////////////////////////////////////////////////// + //ZIP handling + + /// + /// Get zip entries for a utlity file + /// + /// + /// + internal static List ZipGetUtilityFileEntries(string zipFileName) + { + return ZipGetEntries(GetFullPathForUtilityFile(zipFileName)); + } + + /// + /// Get zip entries for full path and file name + /// returns the entry fullname sorted alphabetically so that folders stay together + /// + /// + /// + internal static List ZipGetEntries(string zipPath) + { + List zipEntries = new List(); + using (ZipArchive archive = ZipFile.OpenRead(zipPath)) + { + foreach (ZipArchiveEntry entry in archive.Entries) + { + zipEntries.Add(entry.FullName); + } + } + zipEntries.Sort(); + return zipEntries; + } + + /// + /// Import utility - get individual files specified in zip archive as JSON objects + /// + /// + /// Name of utility zip import file + /// Name of entries in utility file archive to fetch + /// + internal static List ZipGetUtilityArchiveEntriesAsJsonObjects(List entryList, string zipFileName) + { + List jList = new List(); + var zipPath = GetFullPathForUtilityFile(zipFileName); + using (ZipArchive archive = ZipFile.OpenRead(zipPath)) + { + foreach (string importFileName in entryList) + { + ZipArchiveEntry entry = archive.GetEntry(importFileName); + if (entry != null) + { + //stream entry into a new jobject and add it to the list + StreamReader reader = new StreamReader(entry.Open()); + string text = reader.ReadToEnd(); + var j = JObject.Parse(text); + + //Here add v7 import file name as sometimes it's needed later (locales) + j.Add("V7_SOURCE_FILE_NAME", JToken.FromObject(importFileName)); + jList.Add(j); + } + } + + } + + return jList; + + } + + + #endregion Zip handling + + #region Attachment file handling + + /// + /// Get user folder + /// + /// + internal static string UserFilesFolder + { + get + { + return ServerBootConfig.AYANOVA_FOLDER_USER_FILES; + } + } + + + /// + /// Get a random file name + /// + /// + internal static string NewRandomFileName + { + get + { + return Path.GetRandomFileName(); + } + } + + /// + /// Get a random file name with path to attachments folder + /// + /// + internal static string NewRandomAttachmentFileName + { + get + { + return Path.Combine(UserFilesFolder, NewRandomFileName); + } + } + + + + /// + /// Store a file attachment + /// + /// + /// + /// + /// + /// + /// + /// + internal static FileAttachment storeFileAttachment(string tempFilePath, string contentType, string fileName, long userId, AyaTypeId attachToObject, AyContext ct) + { + //calculate hash + var hash = FileHash.GetChecksum(tempFilePath); + + //Move to folder based on hash + var permanentPath = GetPermanentAttachmentPath(hash); + EnsurePath(permanentPath); + + var permanentFilePath = Path.Combine(permanentPath, hash); + + //See if the file was already uploaded, if so then ignore it for now + if (File.Exists(permanentFilePath)) + { + //delete the temp file, it's already stored + File.Delete(tempFilePath); + } + else + { + System.IO.File.Move(tempFilePath, permanentFilePath); + } + + + //Build AyFileInfo + FileAttachment fi = new FileAttachment() + { + OwnerId = userId, + StoredFileName = hash, + DisplayFileName = fileName, + Notes = string.Empty, + ContentType = contentType, + AttachToObjectId = attachToObject.ObjectId, + AttachToObjectType = attachToObject.ObjectType + }; + + //Store in DB + ct.FileAttachment.Add(fi); + ct.SaveChanges(); + + //Return AyFileInfo object + return fi; + + } + + /// + ///use first three characters for name of folders one character per folder, i.e.: + ///if the checksum is f6a5b1236dbba1647257cc4646308326 + ///it would be stored in userfiles/f/6/a/f6a5b1236dbba1647257cc4646308326 + /// + /// + /// Path without the file + internal static string GetPermanentAttachmentPath(string hash) + { + return Path.Combine(UserFilesFolder, hash[0].ToString(), hash[1].ToString(), hash[2].ToString()); + } + + /// + /// Get the whole path including file name not just the folder + /// + /// + /// + internal static string GetPermanentAttachmentFilePath(string hash) + { + return Path.Combine(UserFilesFolder, hash[0].ToString(), hash[1].ToString(), hash[2].ToString(), hash); + } + + + /// + /// Delete a file attachment + /// checks ref count and if would be zero deletes file physically + /// otherwise just deletes pointer in db + /// + /// + /// + /// + internal static FileAttachment deleteFileAttachment(FileAttachment fileAttachmentToBeDeleted, AyContext ct) + { + + //check ref count of file + var count = ct.FileAttachment.Count(w => w.StoredFileName == fileAttachmentToBeDeleted.StoredFileName); + + //Store in DB + ct.FileAttachment.Remove(fileAttachmentToBeDeleted); + ct.SaveChanges(); + + if (count < 2) + { + //remove the file completely + var permanentPath = GetPermanentAttachmentPath(fileAttachmentToBeDeleted.StoredFileName); + var permanentFilePath = Path.Combine(permanentPath, fileAttachmentToBeDeleted.StoredFileName); + + if (File.Exists(permanentFilePath)) + { + //delete the temp file, it's already stored + File.Delete(permanentFilePath); + } + } + + //Return AyFileInfo object + return fileAttachmentToBeDeleted; + + } + + /// + /// DANGER: Erases all user files + /// + internal static void EraseEntireContentsOfUserFilesFolder() + { + System.IO.DirectoryInfo di = new DirectoryInfo(UserFilesFolder); + foreach (FileInfo file in di.EnumerateFiles()) + { + file.Delete(); + } + foreach (DirectoryInfo dir in di.EnumerateDirectories()) + { + dir.Delete(true); + } + } + + + #endregion attachment stuff + + #region General utilities + + /// + /// Attachments / user files folder size info + /// + /// + internal static FolderSizeInfo GetAttachmentFolderSizeInfo() + { + return GetDirectorySize(new DirectoryInfo(UserFilesFolder)); + } + + /// + /// Utility / backup folder file size info + /// + /// + internal static FolderSizeInfo GetUtilityFolderSizeInfo() + { + return GetDirectorySize(new DirectoryInfo(UtilityFilesFolder)); + } + + + /// + /// Calculate disk space usage under . If is provided, + /// then return subdirectory disk usages as well, up to levels deep. + /// If levels is not provided or is 0, return a list with a single element representing the + /// directory specified by . + /// + /// FROM https://stackoverflow.com/a/28094795/8939 + /// + /// + /// + public static FolderSizeInfo GetDirectorySize(DirectoryInfo root, int levels = 0) + { + var currentDirectory = new FolderSizeInfo(); + + // Add file sizes. + FileInfo[] fis = root.GetFiles(); + currentDirectory.Size = 0; + foreach (FileInfo fi in fis) + { + currentDirectory.Size += fi.Length; + } + + // Add subdirectory sizes. + DirectoryInfo[] dis = root.GetDirectories(); + + currentDirectory.Path = root; + currentDirectory.SizeWithChildren = currentDirectory.Size; + currentDirectory.DirectoryCount = dis.Length; + currentDirectory.DirectoryCountWithChildren = dis.Length; + currentDirectory.FileCount = fis.Length; + currentDirectory.FileCountWithChildren = fis.Length; + + if (levels >= 0) + currentDirectory.Children = new List(); + + foreach (DirectoryInfo di in dis) + { + var dd = GetDirectorySize(di, levels - 1); + if (levels >= 0) + currentDirectory.Children.Add(dd); + + currentDirectory.SizeWithChildren += dd.SizeWithChildren; + currentDirectory.DirectoryCountWithChildren += dd.DirectoryCountWithChildren; + currentDirectory.FileCountWithChildren += dd.FileCountWithChildren; + } + + return currentDirectory; + } + + public class FolderSizeInfo + { + public DirectoryInfo Path { get; set; } + public long SizeWithChildren { get; set; } + public long Size { get; set; } + public int DirectoryCount { get; set; } + public int DirectoryCountWithChildren { get; set; } + public int FileCount { get; set; } + public int FileCountWithChildren { get; set; } + public List Children { get; set; } + } + #endregion general utilities + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/Hasher.cs b/server/AyaNova/util/Hasher.cs new file mode 100644 index 00000000..c27e30e7 --- /dev/null +++ b/server/AyaNova/util/Hasher.cs @@ -0,0 +1,53 @@ +using System; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +namespace AyaNova.Util +{ + + + public static class Hasher + { + + + public static string hash(string Salt, string Password) + { + + //adapted from here: + //https://docs.microsoft.com/en-us/aspnet/core/security/data-protection/consumer-apis/password-hashing + string hashed = Convert.ToBase64String(KeyDerivation.Pbkdf2( + password: Password, + salt: Convert.FromBase64String(Salt), + prf: KeyDerivationPrf.HMACSHA512, + iterationCount: 10000, + numBytesRequested: 512 / 8)); + return hashed; + } + + //Generate salt + public static string GenerateSalt() + { + var salt = new byte[32]; + var random = RandomNumberGenerator.Create(); + random.GetNonZeroBytes(salt); + return Convert.ToBase64String(salt); + } + + + // /// + // /// Generate a random ID + // /// + // /// HEX + // internal static string GenerateStrongId() + // { + // var s = new byte[32]; + // var random = RandomNumberGenerator.Create(); + // random.GetNonZeroBytes(s); + // return BitConverter.ToString(s).Replace("-", string.Empty).ToLowerInvariant(); + // } + + + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/IsLocalExtension.cs b/server/AyaNova/util/IsLocalExtension.cs new file mode 100644 index 00000000..bb8ab09e --- /dev/null +++ b/server/AyaNova/util/IsLocalExtension.cs @@ -0,0 +1,31 @@ +using System.Net; +using Microsoft.AspNetCore.Http; + + +//https://stackoverflow.com/a/41242493/8939 +public static class IsLocalExtension +{ + + private const string NullIpAddress = "::1"; + + public static bool IsLocal(this HttpRequest req) + { + var connection = req.HttpContext.Connection; + if (connection.RemoteIpAddress.IsSet()) + { + //We have a remote address set up + return connection.LocalIpAddress.IsSet() + //Is local is same as remote, then we are local + ? connection.RemoteIpAddress.Equals(connection.LocalIpAddress) + //else we are remote if the remote IP address is not a loopback address + : IPAddress.IsLoopback(connection.RemoteIpAddress); + } + + return true; + } + + private static bool IsSet(this IPAddress address) + { + return address != null && address.ToString() != NullIpAddress; + } +} diff --git a/server/AyaNova/util/License.cs b/server/AyaNova/util/License.cs new file mode 100644 index 00000000..309d0c27 --- /dev/null +++ b/server/AyaNova/util/License.cs @@ -0,0 +1,645 @@ +using System; +using System.Text; +using System.Threading.Tasks; +using System.IO; +using System.Collections.Generic; +using System.Net.Http; +using AyaNova.Util; +using AyaNova.Models; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; + +//JSON KEY +using Newtonsoft.Json; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.OpenSsl; +//using System.Security.Cryptography; +//using Microsoft.AspNetCore.Cryptography.KeyDerivation; + + + + +namespace AyaNova.Core +{ + + + + internal static class License + { + + //License server address + private const string LICENSE_SERVER_URL = "https://rockfish.ayanova.com/"; + // private const string LICENSE_SERVER_URL = "http://localhost:5000/"; + + //Scheduleable users + private const string SERVICE_TECHS_FEATURE_NAME = "ServiceTechs"; + + //Accounting add-on + private const string ACCOUNTING_FEATURE_NAME = "Accounting"; + + //This feature name means it's a trial key + private const string TRIAL_FEATURE_NAME = "TrialMode"; + + //This feature name means it's a SAAS or rental mode key for month to month hosted service + private const string RENTAL_FEATURE_NAME = "ServiceMode"; + + + //Trial key magic number for development and testing, all other guids will be fully licensed + private static Guid TEST_TRIAL_KEY_DBID = new Guid("{A6D18A8A-5613-4979-99DA-80D07641A2FE}"); + + //https://aspnetmonsters.com/2016/08/2016-08-27-httpclientwrong/ + private static HttpClient _Client = new HttpClient(); + + //Current license key, can be empty + private static AyaNovaLicenseKey _ActiveLicense = new AyaNovaLicenseKey(); + + //The one and only DBID + private static Guid DbId { get; set; } + + #region license classes + + //DTO object returned on license query + internal class LicenseFeature + { + //name of feature / product + public string Feature { get; set; } + + //Optional count for items that require it + public long Count { get; set; } + + } + + //DTO object for parsed key + internal class AyaNovaLicenseKey + { + public AyaNovaLicenseKey() + { + Features = new List(); + RegisteredTo = "UNLICENSED"; + Id = RegisteredTo; + } + + + /// + /// Fetch the license status of the feature in question + /// + /// + /// LicenseFeature object or null if there is no license + public LicenseFeature GetLicenseFeature(string Feature) + { + if (IsEmpty) + return null; + + string lFeature = Feature.ToLowerInvariant(); + + foreach (LicenseFeature l in Features) + { + if (l.Feature.ToLowerInvariant() == lFeature) + { + return l; + } + } + return null; + } + + + /// + /// Check for the existance of license feature + /// + /// + /// bool + public bool HasLicenseFeature(string Feature) + { + if (IsEmpty) + return false; + + string lFeature = Feature.ToLowerInvariant(); + + foreach (LicenseFeature l in Features) + { + if (l.Feature.ToLowerInvariant() == lFeature) + { + return true; + } + } + return false; + } + + public bool IsEmpty + { + get + { + //Key is empty if it's not registered to anyone or there are no features in it + return string.IsNullOrWhiteSpace(RegisteredTo) || (Features == null || Features.Count == 0); + } + } + + public bool WillExpire + { + get + { + return LicenseExpiration < DateUtil.EmptyDateValue; + } + } + + + public bool LicenseExpired + { + get + { + return LicenseExpiration < DateTime.Now; + } + } + + public bool MaintenanceExpired + { + get + { + return MaintenanceExpiration < DateTime.Now; + } + } + + + public bool TrialLicense + { + get + { + return HasLicenseFeature(TRIAL_FEATURE_NAME); + } + } + + public bool RentalLicense + { + get + { + return HasLicenseFeature(RENTAL_FEATURE_NAME); + } + } + + public string LicenseFormat { get; set; } + public string Id { get; set; } + public string RegisteredTo { get; set; } + public Guid DbId { get; set; } + public DateTime LicenseExpiration { get; set; } + public DateTime MaintenanceExpiration { get; set; } + public List Features { get; set; } + + + } + #endregion + + #region sample v8 key + // private static string SAMPLE_KEY = @"[KEY + // { + // ""Key"": { + // ""LicenseFormat"": ""2018"", + // ""Id"": ""34-1516288681"", <----Customer id followed by key serial id + // ""RegisteredTo"": ""Super TestCo"", + // ""DBID"": ""df558559-7f8a-4c7b-955c-959ebcdf71f3"", + // ""LicenseExpiration"": ""2019-01-18T07:18:01.2329138-08:00"", <--- UTC, DateTime if perpetual license 1/1/5555 indicates not expiring + // ""MaintenanceExpiration"": ""2019-01-18T07:18:01.2329138-08:00"", <-- UTC, DateTime support and updates subscription runs out, applies to all features + // ""Features"": { + // ""Feature"": [ + // { + // ""Name"": ""Scheduleable users"", + // ""Count"":""10"", + // }, + // { + // ""Name"": ""Accounting"" + // }, + // { + // ""Name"": ""TrialMode""<---means is a trial key + // }, + // { + // ""Name"": ""ServiceMode"" <----Means it's an SAAS/Rental key + // } + + // ] + // } + // } + // } + // KEY] + // [SIGNATURE + // HEcY3JCVwK9HFXEFnldUEPXP4Q7xoZfMZfOfx1cYmfVF3MVWePyZ9dqVZcY7pk3RmR1BbhQdhpljsYLl+ZLTRhNa54M0EFa/bQnBnbwYZ70EQl8fz8WOczYTEBo7Sm5EyC6gSHtYZu7yRwBvhQzpeMGth5uWnlfPb0dMm0DQM7PaqhdWWW9GCSOdZmFcxkFQ8ERLDZhVMbd8PJKyLvZ+sGMrmYTAIoL0tqa7nrxYkM71uJRTAmQ0gEl4bJdxiV825U1J+buNQuTZdacZKEPSjQQkYou10jRbReUmP2vDpvu+nA1xdJe4b5LlyQL+jiIXH17lf93xlCUb0UkDpu8iNQ== + // SIGNATURE]\"; + + #endregion + + #region Exposed properties + + + + /// + /// Fetch a summary of the license key for displaying to the end user + /// + /// string containing current license information + internal static string LicenseInfo + { + get + { + StringBuilder sb = new StringBuilder(); + + if (ActiveKey.IsEmpty) + { + sb.AppendLine("UNLICENSED"); + sb.AppendLine($"DB ID: {DbId}"); + } + else + { + if (ActiveKey.TrialLicense) + sb.AppendLine("TRIAL LICENSE FOR EVALUATION PURPOSES ONLY"); + + sb.AppendLine($"Registered to: {ActiveKey.RegisteredTo}"); + sb.AppendLine($"Key ID: {ActiveKey.Id}"); + sb.AppendLine($"DB ID: {DbId}"); + sb.AppendLine($"Type: {(ActiveKey.RentalLicense ? "Service" : "Perpetual")}"); + if (ActiveKey.WillExpire) + sb.AppendLine($"License expires: {DateUtil.ServerDateTimeString(ActiveKey.LicenseExpiration)}"); + sb.AppendLine($"Maintenance subscription expires: {DateUtil.ServerDateTimeString(ActiveKey.MaintenanceExpiration)}"); + sb.AppendLine("Features:"); + foreach (LicenseFeature l in ActiveKey.Features) + { + //don't show the rental or trial features + if (l.Feature != TRIAL_FEATURE_NAME && l.Feature != RENTAL_FEATURE_NAME) + { + if (l.Count != 0) + sb.AppendLine($"{l.Feature} - {l.Count}"); + else + sb.AppendLine($"{l.Feature}"); + } + } + + } + return sb.ToString(); + } + } + + + /// + /// Fetch a summary of the license key for displaying to the end user + /// via the API + /// + /// JSON object containing current license information + internal static object LicenseInfoAsJson + { + get + { + Newtonsoft.Json.Linq.JObject o = Newtonsoft.Json.Linq.JObject.FromObject(new + { + license = new + { + licensedTo = ActiveKey.RegisteredTo, + dbId = ActiveKey.DbId, + keySerial = ActiveKey.Id, + licenseExpiration = ActiveKey.LicenseExpiration, + maintenanceExpiration = ActiveKey.MaintenanceExpiration, + features = + from f in ActiveKey.Features + orderby f.Feature + select new + { + Feature = f.Feature, + Count = f.Count + } + } + }); + return o; + } + } + + + /// + /// Returns the active key + /// + /// + internal static AyaNovaLicenseKey ActiveKey + { + get + { + return _ActiveLicense; + } + } + + + + #endregion + + + #region Trial license request handling + /// + /// Request a key + /// + /// Result string + internal static string RequestTrial(string email, string regto, ILogger log) + { + //TODO: TESTING REMOVE BEFORE RELEASE + //for test purposes if this route is hit and this code executed then the dbid is temporarily changed to the special trial request + //dbid so I can test remotely without hassle + //TO USE: just hit the trial key request route once then the license fetch route and it should be easy peasy + log.LogCritical("WARNING License::RequestTrial - DEVELOPMENT TEST FORCING TRIAL DB KEY ID. UPDATE BEFORE RELEASE!!"); + DbId=TEST_TRIAL_KEY_DBID; + //TESTING + + + + Microsoft.AspNetCore.Http.Extensions.QueryBuilder q = new Microsoft.AspNetCore.Http.Extensions.QueryBuilder(); + q.Add("dbid", DbId.ToString()); + q.Add("email", email); + q.Add("regto", regto); + + log.LogDebug($"Requesting trial license for DBID {DbId.ToString()}"); + string sUrl = $"{LICENSE_SERVER_URL}rvr" + q.ToQueryString(); + try + { + var res = _Client.GetStringAsync(sUrl).Result; + return res; + } + catch (Exception ex) + { + var msg = "E1020 - Error requesting trial license key see log for details"; + log.LogError(ex, msg); + return msg; + // throw new ApplicationException(msg, ex); + } + } + #endregion trial license request handling + + + #region License fetching and handling + + + /// + /// Fetch a key, validate it and install it in the db then initialize with it + /// + /// Result string + internal static void Fetch(AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, AyContext ctx, ILogger log) + { + log.LogDebug($"Fetching license for DBID {DbId.ToString()}"); + string sUrl = $"{LICENSE_SERVER_URL}rvf/{DbId.ToString()}"; + +#if (DEBUG) + log.LogInformation("DEBUG MODE TRIAL LICENSE KEY BEING FETCHED"); + sUrl = $"{LICENSE_SERVER_URL}rvf/{TEST_TRIAL_KEY_DBID.ToString()}"; +#endif + + try + { + string RawTextKeyFromRockfish = _Client.GetStringAsync(sUrl).Result; + //FUTURE: if there is any kind of error response or REASON or LicenseFetchStatus then here is + //where to deal with it + + AyaNovaLicenseKey ParsedKey = Parse(RawTextKeyFromRockfish, log); + if (ParsedKey != null) + { + Install(RawTextKeyFromRockfish, ParsedKey, apiServerState, ctx, log); + } + } + catch (Exception ex) + { + var msg = "E1020 - Error fetching license key"; + log.LogError(ex, msg); + throw new ApplicationException(msg, ex); + } + } + + + /// + /// Initialize the key + /// Handle if first boot scenario to tag DB ID etc + /// + internal static void Initialize(AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, AyContext ctx, ILogger log) + { + log.LogDebug("Initializing license"); + + try + { + //Fetch key from db as no tracking so doesn't hang round if need to immediately clear and then re-add the key + Models.License ldb = ctx.License.AsNoTracking().SingleOrDefault(); + + //Non existent license should restrict server to ops routes only with closed API + if (ldb == null) + { + ldb = new Models.License(); + ldb.DbId = Guid.NewGuid(); + ldb.LastFetchStatus = 0; + ldb.Key = "none"; + ldb.LastFetchMessage = "none"; + ctx.License.Add(ldb); + ctx.SaveChanges(); + } + + //ensure DB ID + if (ldb.DbId == Guid.Empty) + { + ldb.DbId = Guid.NewGuid(); + //Convert the no tracking record fetched above to tracking + //this is required because a prior call to initialize before dumping the db would mean the license is still in memory in the context + ctx.Entry(ldb).State = Microsoft.EntityFrameworkCore.EntityState.Modified; + ctx.SaveChanges(); + } + + //Get it early and set it here so that it can be displayed early to the user even if not licensed + DbId = ldb.DbId; + + if (ldb.Key == "none") + { + var msg = "License key not found in database, running in unlicensed mode"; + apiServerState.SetSystemLock(msg); + log.LogWarning(msg); + return; + } + + //Validate the key + AyaNovaLicenseKey k = Parse(ldb.Key, log); + if (k == null) + { + var msg = "Error: License key in database is not valid, running in unlicensed mode"; + apiServerState.SetSystemLock(msg); + log.LogError(msg); + return; + } + + _ActiveLicense = k; + + if (_ActiveLicense.LicenseExpired) + { + var msg = $"License key expired {DateUtil.ServerDateTimeString(_ActiveLicense.LicenseExpiration)}"; + apiServerState.SetSystemLock(msg); + log.LogWarning(msg); + return; + } + + //Key is ok, might not have been on first boot so check and clear if locked + //This works for now because system lock only means license lock + //if ever changed for other purposes then need to handle that see serverstate for ideas + if (apiServerState.IsSystemLocked) + { + apiServerState.ClearSystemLock(); + } + log.LogDebug("License key OK"); + } + catch (Exception ex) + { + var msg = "E1020 - Error initializing license key"; + log.LogError(ex, msg); + throw new ApplicationException(msg, ex); + } + } + + + /// + /// Install key to db + /// + private static bool Install(string RawTextNewKey, AyaNovaLicenseKey ParsedNewKey, AyaNova.Api.ControllerHelpers.ApiServerState apiServerState, AyContext ctx, ILogger log) + { + try + { + var CurrentInDbKeyRecord = ctx.License.FirstOrDefault(); + if (CurrentInDbKeyRecord == null) + throw new ApplicationException("E1020 - Can't install key, no key record found"); + + if (ParsedNewKey == null) + { + throw new ApplicationException("License.Install -> key could not be parsed"); + } + + //Can't install a trial into a non-empty db + if (ParsedNewKey.TrialLicense && !DbUtil.DBIsEmpty(ctx, log)) + { + throw new ApplicationException("E1020 - Can't install a trial key into a non empty AyaNova database. Erase the database first."); + } + + //Update current license + CurrentInDbKeyRecord.Key = RawTextNewKey; + //TODO: reason, resultcode etc + ctx.SaveChanges(); + } + catch (Exception ex) + { + var msg = "E1020 - Error installing license key"; + log.LogError(ex, msg); + throw new ApplicationException(msg, ex); + } + finally + { + Initialize(apiServerState, ctx, log); + } + return true; + } + + + #endregion + + #region PARSE and Validate key + /// + /// Parses and validates the integrity of a passed in textual license key + /// + /// a populated key if valid or else null + private static AyaNovaLicenseKey Parse(string k, ILogger log) + { + AyaNovaLicenseKey key = new AyaNovaLicenseKey(); + + log.LogDebug("Validating license"); + + if (string.IsNullOrWhiteSpace(k)) + { + throw new ApplicationException("License.Parse -> License key is empty and can't be validated"); + } + + try + { + if (!k.Contains("[KEY") || + !k.Contains("KEY]") || + !k.Contains("[SIGNATURE") || + !k.Contains("SIGNATURE]")) + { + throw new ApplicationException("License.Parse -> License key is missing required delimiters"); + + } + + + string keyNoWS = System.Text.RegularExpressions.Regex.Replace(StringUtil.Extract(k, "[KEY", "KEY]").Trim(), "(\"(?:[^\"\\\\]|\\\\.)*\")|\\s+", "$1"); + string keySig = StringUtil.Extract(k, "[SIGNATURE", "SIGNATURE]").Trim(); + + #region Check Signature + + //***** NOTE: this is our real 2016 public key + var publicPem = @"-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz7wrvLDcKVMZ31HFGBnL +WL08IodYIV5VJkKy1Z0n2snprhSiu3izxTyz+SLpftvKHJpky027ii7l/pL9Bo3J +cjU5rKrxXavnE7TuYPjXn16dNLd0K/ERSU+pXLmUaVN0nUWuGuUMoGJMEXoulS6p +JiG11yu3BM9fL2Nbj0C6a+UwzEHFmns3J/daZOb4gAzMUdJfh9OJ0+wRGzR8ZxyC +99Na2gDmqYglUkSMjwLTL/HbgwF4OwmoQYJBcET0Wa6Gfb17SaF8XRBV5ZtpCsbS +tkthGeoXZkFriB9c1eFQLKpBYQo2DW3H1MPG2nAlQZLbkJj5cSh7/t1bRF08m6P+ +EQIDAQAB +-----END PUBLIC KEY-----"; + + + PemReader pr = new PemReader(new StringReader(publicPem)); + var KeyParameter = (Org.BouncyCastle.Crypto.AsymmetricKeyParameter)pr.ReadObject(); + var signer = SignerUtilities.GetSigner("SHA256WITHRSA"); + signer.Init(false, KeyParameter); + var expectedSig = Convert.FromBase64String(keySig); + var msgBytes = Encoding.UTF8.GetBytes(keyNoWS); + signer.BlockUpdate(msgBytes, 0, msgBytes.Length); + if (!signer.VerifySignature(expectedSig)) + { + throw new ApplicationException("License.Parse -> License key failed integrity check and is not valid"); + } + + #endregion check signature + + #region Get Values + Newtonsoft.Json.Linq.JToken token = Newtonsoft.Json.Linq.JObject.Parse(keyNoWS); + + key.LicenseFormat = (string)token.SelectToken("Key.LicenseFormat"); + if (key.LicenseFormat != "2018") + throw new ApplicationException($"License.Parse -> License key format {key.LicenseFormat} not recognized"); + key.Id = (string)token.SelectToken("Key.Id"); + key.RegisteredTo = (string)token.SelectToken("Key.RegisteredTo"); + key.DbId = (Guid)token.SelectToken("Key.DBID"); + key.LicenseExpiration = (DateTime)token.SelectToken("Key.LicenseExpiration"); + key.MaintenanceExpiration = (DateTime)token.SelectToken("Key.MaintenanceExpiration"); + + //FEATURES + Newtonsoft.Json.Linq.JArray p = (Newtonsoft.Json.Linq.JArray)token.SelectToken("Key.Features"); + for (int x = 0; x < p.Count; x++) + { + LicenseFeature lf = new LicenseFeature(); + lf.Feature = (string)p[x].SelectToken("Name"); + if (p[x].SelectToken("Count") != null) + { + lf.Count = (long)p[x].SelectToken("Count"); + } + else + { + lf.Count = 0; + } + key.Features.Add(lf); + } + + + #endregion get values + //All is well return key + return key; + + } + catch (Exception ex) + { + var msg = "E1020 - License key not valid"; + log.LogError(ex, msg); + throw new ApplicationException(msg, ex); + } + + + } + #endregion + + + + + }//eoc + +}//eons diff --git a/server/AyaNova/util/MetricsRegistry.cs b/server/AyaNova/util/MetricsRegistry.cs new file mode 100644 index 00000000..8a7f157b --- /dev/null +++ b/server/AyaNova/util/MetricsRegistry.cs @@ -0,0 +1,167 @@ +using App.Metrics; +using App.Metrics.Counter; +using App.Metrics.Gauge; +using App.Metrics.Histogram; +using App.Metrics.ReservoirSampling.Uniform; +using App.Metrics.Meter; +using App.Metrics.Timer; +using App.Metrics.Apdex; +using App.Metrics.ReservoirSampling.ExponentialDecay; + +namespace AyaNova.Util +{ + /// + /// All metrics gathered by AyaNova are defined here + /// (except for endpoint ones gathered automatically by App.Metrics) + /// https://www.app-metrics.io + /// + public static class MetricsRegistry + { + + /// + /// Physical memory + /// Memory being used by this process (RAVEN) + /// + public static GaugeOptions PhysicalMemoryGauge = new GaugeOptions + { + Name = "Process Physical Memory", + MeasurementUnit = Unit.Bytes + }; + + /// + /// Private bytes + /// The current size, in bytes, of the committed memory owned by this process. + /// Memory leaks are identified by a consistent and prolonged increase in Private Bytes. + /// This is the best performance counter for detecting memory leaks. + /// + public static GaugeOptions PrivateBytesGauge = new GaugeOptions + { + Name = "Process Private Bytes", + MeasurementUnit = Unit.Bytes + }; + + /// + /// Exceptions that are handled by the ApiCustomExceptionFilter + /// Basically any exception that is not normal and expected + /// + public static MeterOptions UnhandledExceptionsMeter => new MeterOptions + { + Name = "Exceptions Meter", + MeasurementUnit = Unit.Calls + }; + + /// + /// Login failed meter + /// + public static MeterOptions FailedLoginMeter => new MeterOptions + { + Name = "Failed Login Meter", + MeasurementUnit = Unit.Calls + }; + + /// + /// Login failed meter + /// + public static MeterOptions SuccessfulLoginMeter => new MeterOptions + { + Name = "Successful Login Meter", + MeasurementUnit = Unit.Calls + }; + + /// + /// Records in db + /// + public static GaugeOptions DBRecordsGauge = new GaugeOptions + { + Name = "DB Records", + MeasurementUnit = Unit.Items + }; + + /// + /// Jobs in db + /// + public static GaugeOptions JobsGauge = new GaugeOptions + { + Name = "Jobs", + MeasurementUnit = Unit.Items + }; + + /// + /// File count on disk + /// + public static GaugeOptions FileCountGauge = new GaugeOptions + { + Name = "File count", + MeasurementUnit = Unit.Items + }; + + /// + /// File size on disk + /// + public static GaugeOptions FileSizeGauge = new GaugeOptions + { + Name = "File size", + MeasurementUnit = Unit.Bytes + }; + + + + + + + + // ================================================================== + // /// + // /// + // /// + // public static GaugeOptions Errors => new GaugeOptions + // { + // Context = "My_Gauge_context", + // Name = "Errors" + // }; + + + + // /// + // /// + // /// + // public static HistogramOptions SampleHistogram => new HistogramOptions + // { + // Name = "Sample Histogram", + // Reservoir = () => new DefaultAlgorithmRReservoir(), + // MeasurementUnit = Unit.MegaBytes + // }; + + // /// + // /// + // /// + // public static MeterOptions SampleMeter => new MeterOptions + // { + // Name = "Sample Meter", + // MeasurementUnit = Unit.Calls + // }; + + // /// + // /// + // /// + // public static TimerOptions SampleTimer => new TimerOptions + // { + // Name = "Sample Timer", + // MeasurementUnit = Unit.Items, + // DurationUnit = TimeUnit.Milliseconds, + // RateUnit = TimeUnit.Milliseconds, + // Reservoir = () => new DefaultForwardDecayingReservoir(sampleSize: 1028, alpha: 0.015) + // }; + + // /// + // /// + // /// + // public static ApdexOptions SampleApdex => new ApdexOptions + // { + // Name = "Sample Apdex" + // }; + + + + } +} diff --git a/server/AyaNova/util/RetryHelper.cs b/server/AyaNova/util/RetryHelper.cs new file mode 100644 index 00000000..fec85af7 --- /dev/null +++ b/server/AyaNova/util/RetryHelper.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace AyaNova.Util +{ + + /// + /// what it says + /// + public static class RetryHelper + { + //private static ILog logger = LogManager.GetLogger(); //use a logger or trace of your choice + // private readonly ILogger log; + /// + /// + /// + /// + /// + /// + /// + /// + public static void RetryOnException(int times, TimeSpan delay, ILogger log, string logPrepend, Action operation) + { + var attempts = 0; + do + { + try + { + attempts++; + operation(); + break; // Sucess! Lets exit the loop! + } + catch (Exception ex) + { + if (attempts == times) + throw; + + log.LogError(ex, $"{logPrepend} Exception caught on attempt {attempts} - will retry after delay {delay}"); + + Task.Delay(delay).Wait(); + } + } while (true); + } + + + + + + + + } +} \ No newline at end of file diff --git a/server/AyaNova/util/Seeder.cs b/server/AyaNova/util/Seeder.cs new file mode 100644 index 00000000..44f13151 --- /dev/null +++ b/server/AyaNova/util/Seeder.cs @@ -0,0 +1,286 @@ +using System; +using AyaNova.Models; +using AyaNova.Biz; +using Microsoft.Extensions.Logging; +using Microsoft.EntityFrameworkCore; +using Bogus; +using AyaNova.Api.ControllerHelpers; + + +namespace AyaNova.Util +{ + + public static class Seeder + { + + public enum SeedLevel { SmallOneManShopTrialDataSet, MediumLocalServiceCompanyTrialDataSet, LargeCorporateMultiRegionalTrialDataSet }; + + // ////////////////////////////////////////////////////// + // //Seed database with default manager account + // // + // public static User GenerateDefaultManagerAccountUser() + // { + // User u = new User(); + // u.Name = "AyaNova Administrator"; + // u.Salt = Hasher.GenerateSalt(); + // u.Login = "manager"; + // u.Password = Hasher.hash(u.Salt, "l3tm3in"); + // u.Roles = AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminFull | AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryFull; + // u.OwnerId = 1; + // return u; + // } + + + ////////////////////////////////////////////////////// + //Seed database for trial and testing purposes + // + public static void SeedDatabase(AyContext ct, SeedLevel slevel) + { + ILogger log = AyaNova.Util.ApplicationLogging.CreateLogger("Seeder"); + ApiServerState apiServerState = (ApiServerState)ServiceProviderProvider.Provider.GetService(typeof(ApiServerState)); + + //get the current server state so can set back to it later + ApiServerState.ServerState wasServerState = apiServerState.GetState(); + string wasReason=apiServerState.Reason; + + try + { + log.LogInformation("SEEDER: SeedDatabase, level is: " + slevel.ToString()); + + //Only allow this in a trial database + if (!AyaNova.Core.License.ActiveKey.TrialLicense) + { + throw new System.NotSupportedException("This database has a registered license key and can't be seeded."); + } + + log.LogInformation("Setting server state to OpsOnly"); + apiServerState.SetOpsOnly("Seeding database"); + + //Erase all the data except for the license, schema and the manager user + DbUtil.PrepareDatabaseForSeeding(log); + + var f = new Faker("en"); + + //Seed special test data for integration testing + SeedTestData(ct); + + + switch (slevel) + { + + //This is for a busy but one man shop with a single office person handling stuff back at the shop + case SeedLevel.SmallOneManShopTrialDataSet: + //Generate owner and lead tech + GenSeedUser(1, ct, AuthorizationRoles.BizAdminFull | AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryFull | AuthorizationRoles.OpsAdminFull); + + //Generate one office person / secretary + GenSeedUser(1, ct, AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryFull | AuthorizationRoles.AccountingFull); + + //200 widgets + GenSeedWidget(200, ct); + + break; + //This is for a typical AyaNova medium busy shop + //has one location, many techs and full staff for each department + case SeedLevel.MediumLocalServiceCompanyTrialDataSet: + + //One IT administrator, can change ops but nothing else + GenSeedUser(1, ct, AuthorizationRoles.OpsAdminFull); + + //One business administrator, can view ops issues + GenSeedUser(1, ct, AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminLimited); + + //One owner who doesn't control anything but views stuff + GenSeedUser(1, ct, AuthorizationRoles.DispatchLimited | AuthorizationRoles.InventoryLimited | AuthorizationRoles.OpsAdminLimited); + + //20 techs + GenSeedUser(20, ct, AuthorizationRoles.TechFull | AuthorizationRoles.DispatchLimited); + + //2 subcontractors + GenSeedUser(2, ct, AuthorizationRoles.SubContractorFull); + + //3 sales / generic office people people + GenSeedUser(3, ct, AuthorizationRoles.DispatchLimited | AuthorizationRoles.InventoryLimited); + + //1 dispatch manager + GenSeedUser(1, ct, AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryLimited); + + //1 Inventory manager + GenSeedUser(1, ct, AuthorizationRoles.InventoryFull | AuthorizationRoles.DispatchLimited); + + //1 accountant / bookkeeper + GenSeedUser(1, ct, AuthorizationRoles.AccountingFull | AuthorizationRoles.BizAdminLimited); + + //10 full on client users + GenSeedUser(10, ct, AuthorizationRoles.ClientLimited); + + //10 limited client users + GenSeedUser(10, ct, AuthorizationRoles.ClientLimited); + + //2000 widgets + GenSeedWidget(2000, ct); + + break; + //this is a large corporation with multiple branches in multiple locations all in the same country + //Each location has a full staff and corporate head office has an overarching staff member in charge of each location + case SeedLevel.LargeCorporateMultiRegionalTrialDataSet: + //IT administrator, can change ops but nothing else + GenSeedUser(2, ct, AuthorizationRoles.OpsAdminFull); + + //business administrator, can view ops issues + GenSeedUser(2, ct, AuthorizationRoles.BizAdminFull | AuthorizationRoles.OpsAdminLimited); + + //owner / upper management who doesn't control anything but views stuff + GenSeedUser(5, ct, AuthorizationRoles.DispatchLimited | AuthorizationRoles.InventoryLimited | AuthorizationRoles.OpsAdminLimited); + + //techs + GenSeedUser(100, ct, AuthorizationRoles.TechFull | AuthorizationRoles.DispatchLimited); + + //limited techs + GenSeedUser(50, ct, AuthorizationRoles.TechLimited | AuthorizationRoles.DispatchLimited); + + //20 subcontractors + GenSeedUser(20, ct, AuthorizationRoles.SubContractorFull); + + //10 limited subcontractors + GenSeedUser(10, ct, AuthorizationRoles.SubContractorLimited); + + //30 sales / generic office people people + GenSeedUser(30, ct, AuthorizationRoles.DispatchLimited | AuthorizationRoles.InventoryLimited); + + //5 dispatch manager + GenSeedUser(5, ct, AuthorizationRoles.DispatchFull | AuthorizationRoles.InventoryLimited); + + //5 Inventory manager + GenSeedUser(5, ct, AuthorizationRoles.InventoryFull | AuthorizationRoles.DispatchLimited); + + //10 Inventory manager assistants + GenSeedUser(5, ct, AuthorizationRoles.InventoryLimited); + + //5 accountant / bookkeeper + GenSeedUser(5, ct, AuthorizationRoles.AccountingFull | AuthorizationRoles.BizAdminLimited); + + //100 full on client users + GenSeedUser(100, ct, AuthorizationRoles.ClientFull); + + //100 limited client users + GenSeedUser(100, ct, AuthorizationRoles.ClientLimited); + + //20000 widgets + GenSeedWidget(20000, ct); + + break; + } + + log.LogInformation("Seeding completed successfully"); + } + catch + { + throw; + } + finally + { + log.LogInformation($"Seeder: setting server state back to {wasServerState.ToString()}"); + apiServerState.SetState(wasServerState, wasReason); + } + } + + + + + ////////////////////////////////////////////////////// + //Seed test data for integration tests + // + public static void SeedTestData(AyContext ct) + { + //TEST USERS + //one of each role type + GenSeedUser(1, ct, AuthorizationRoles.BizAdminLimited, "BizAdminLimited", "BizAdminLimited"); + GenSeedUser(1, ct, AuthorizationRoles.BizAdminFull, "BizAdminFull", "BizAdminFull"); + GenSeedUser(1, ct, AuthorizationRoles.DispatchLimited, "DispatchLimited", "DispatchLimited"); + GenSeedUser(1, ct, AuthorizationRoles.DispatchFull, "DispatchFull", "DispatchFull"); + GenSeedUser(1, ct, AuthorizationRoles.InventoryLimited, "InventoryLimited", "InventoryLimited"); + GenSeedUser(1, ct, AuthorizationRoles.InventoryFull, "InventoryFull", "InventoryFull"); + GenSeedUser(1, ct, AuthorizationRoles.AccountingFull, "Accounting", "Accounting"); + GenSeedUser(1, ct, AuthorizationRoles.TechLimited, "TechLimited", "TechLimited"); + GenSeedUser(1, ct, AuthorizationRoles.TechFull, "TechFull", "TechFull"); + GenSeedUser(1, ct, AuthorizationRoles.SubContractorLimited, "SubContractorLimited", "SubContractorLimited"); + GenSeedUser(1, ct, AuthorizationRoles.SubContractorFull, "SubContractorFull", "SubContractorFull"); + GenSeedUser(1, ct, AuthorizationRoles.ClientLimited, "ClientLimited", "ClientLimited"); + GenSeedUser(1, ct, AuthorizationRoles.ClientFull, "ClientFull", "ClientFull"); + GenSeedUser(1, ct, AuthorizationRoles.OpsAdminLimited, "OpsAdminLimited", "OpsAdminLimited"); + GenSeedUser(1, ct, AuthorizationRoles.OpsAdminFull, "OpsAdminFull", "OpsAdminFull"); + + //PRIVACY TEST USER - this is used for a test to see if user info leaks into the logs + GenSeedUser(1, ct, AuthorizationRoles.OpsAdminLimited, "TEST_PRIVACY_USER_ACCOUNT", "TEST_PRIVACY_USER_ACCOUNT"); + + } + + + + + + + ////////////////////////////////////////////////////// + //Seed user - default login / pw is first name + // + public static void GenSeedUser(int count, AyContext ct, AuthorizationRoles roles, string login = null, string password = null) + { + + for (int x = 0; x < count; x++) + { + User u = new User(); + var p = new Bogus.Person(); + u.Name = p.FullName; + u.Salt = Hasher.GenerateSalt(); + if (login != null) + { + u.Login = login; + u.Name += " - " + login; + } + else + u.Login = p.FirstName; + if (password != null) + u.Password = Hasher.hash(u.Salt, password); + else + u.Password = Hasher.hash(u.Salt, u.Login); + u.Roles = roles; + u.LocaleId=ServerBootConfig.AYANOVA_DEFAULT_LANGUAGE_ID; + ct.User.Add(u); + } + ct.SaveChanges(); + } + + + ////////////////////////////////////////////////////// + //Seed widget for testing + // + public static void GenSeedWidget(int count, AyContext ct) + { + + for (int x = 0; x < count; x++) + { + Widget o = new Widget(); + var f = new Bogus.Faker(); + o.Name = f.Commerce.ProductName(); + o.Active = f.Random.Bool(); + + o.StartDate = f.Date.Between(DateTime.Now, DateTime.Now.AddMinutes(60)); + o.EndDate = f.Date.Between(DateTime.Now.AddMinutes(90), DateTime.Now.AddHours(5)); + + o.DollarAmount = Convert.ToDecimal(f.Commerce.Price()); + o.OwnerId = 1; + //this is nonsense but just to test an enum + o.Roles = AuthorizationRoles.DispatchLimited | AuthorizationRoles.InventoryLimited | AuthorizationRoles.OpsAdminLimited; + ct.Widget.Add(o); + } + ct.SaveChanges(); + } + + + + }//eoc + + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/ServerBootConfig.cs b/server/AyaNova/util/ServerBootConfig.cs new file mode 100644 index 00000000..e5f48bce --- /dev/null +++ b/server/AyaNova/util/ServerBootConfig.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; +using Microsoft.Extensions.Configuration; + +namespace AyaNova.Util +{ + + /// + /// Contains config values from bootup + /// + internal static class ServerBootConfig + { + + //CONTENTROOTPATH + internal static string AYANOVA_CONTENT_ROOT_PATH { get; set; } //Note: set in startup.cs, not in program.cs as it requires startup IHostingEnvironment + + + //LANGUAGE / LOCALE + internal static string AYANOVA_DEFAULT_LANGUAGE { get; set; } + internal static long AYANOVA_DEFAULT_LANGUAGE_ID { get; set; } //internal setting set at boot by LocaleBiz::ValidateLocales + + //API + internal static string AYANOVA_JWT_SECRET { get; set; } + internal static string AYANOVA_USE_URLS { get; set; } + + //DATABASE + internal static string AYANOVA_DB_CONNECTION { get; set; } + internal static bool AYANOVA_PERMANENTLY_ERASE_DATABASE { get; set; } + + //FILE FOLDERS + internal static string AYANOVA_FOLDER_USER_FILES { get; set; } + internal static string AYANOVA_FOLDER_BACKUP_FILES { get; set; } + + //LOGGING + internal static string AYANOVA_LOG_PATH { get; set; } + internal static string AYANOVA_LOG_LEVEL { get; set; } + internal static bool AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG { get; set; } + + + + //METRICS + internal static bool AYANOVA_METRICS_USE_INFLUXDB { get; set; } + internal static string AYANOVA_METRICS_INFLUXDB_BASEURL { get; set; } + internal static string AYANOVA_METRICS_INFLUXDB_DBNAME { get; set; } + internal static string AYANOVA_METRICS_INFLUXDB_CONSISTENCY { get; set; } + internal static string AYANOVA_METRICS_INFLUXDB_USERNAME { get; set; } + internal static string AYANOVA_METRICS_INFLUXDB_PASSWORD { get; set; } + internal static string AYANOVA_METRICS_INFLUXDB_RETENSION_POLICY { get; set; } + internal static bool AYANOVA_METRICS_INFLUXDB_CREATE_DATABASE_IF_NOT_EXISTS { get; set; } + + /// + /// Populate the config from the configuration found at boot + /// called by program.cs + /// + /// + internal static void SetConfiguration(IConfigurationRoot config) + { + bool? bTemp = null; + + #region SERVER BASICS + + //LANGUAGE + //LocaleBiz will validate this later at boot pfc and ensure a sane default is set (English) + AYANOVA_DEFAULT_LANGUAGE = config.GetValue("AYANOVA_DEFAULT_LANGUAGE"); + AYANOVA_DEFAULT_LANGUAGE = string.IsNullOrWhiteSpace(AYANOVA_DEFAULT_LANGUAGE) ? "en" : AYANOVA_DEFAULT_LANGUAGE; + string lowLocale = AYANOVA_DEFAULT_LANGUAGE.ToLowerInvariant(); + switch (lowLocale) + { + case "en": + case "english": + AYANOVA_DEFAULT_LANGUAGE = "en"; + break; + case "de": + case "deutsch": + case "german": + AYANOVA_DEFAULT_LANGUAGE = "de"; + break; + case "es": + case "español": + case "spanish": + AYANOVA_DEFAULT_LANGUAGE = "es"; + break; + case "fr": + case "français": + case "french": + AYANOVA_DEFAULT_LANGUAGE = "fr"; + break; + default: + AYANOVA_DEFAULT_LANGUAGE = "en"; + break; + } + + + + + //LOGLEVEL + AYANOVA_LOG_LEVEL = config.GetValue("AYANOVA_LOG_LEVEL"); + AYANOVA_LOG_LEVEL = string.IsNullOrWhiteSpace(AYANOVA_LOG_LEVEL) ? "Info" : AYANOVA_LOG_LEVEL; + + //LOGGING DIAGNOSTIC LOG + bTemp = config.GetValue("AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG"); + AYANOVA_LOG_ENABLE_LOGGER_DIAGNOSTIC_LOG = (null == bTemp) ? false : (bool)bTemp; + + //PORT / API + AYANOVA_USE_URLS = config.GetValue("AYANOVA_USE_URLS"); + AYANOVA_USE_URLS = string.IsNullOrWhiteSpace(AYANOVA_USE_URLS) ? "http://*:7575" : AYANOVA_USE_URLS; + + AYANOVA_JWT_SECRET = config.GetValue("AYANOVA_JWT_SECRET"); + + //DB + AYANOVA_DB_CONNECTION = config.GetValue("AYANOVA_DB_CONNECTION"); + bTemp = config.GetValue("AYANOVA_PERMANENTLY_ERASE_DATABASE"); + AYANOVA_PERMANENTLY_ERASE_DATABASE = (null == bTemp) ? false : (bool)bTemp; + + + //FOLDERS + //Log folder + AYANOVA_LOG_PATH = config.GetValue("AYANOVA_LOG_PATH"); + + if (AYANOVA_LOG_PATH == null) + { + //DEFAULT LOG PATH + var currentDir = Directory.GetCurrentDirectory(); + AYANOVA_LOG_PATH = Path.Combine(currentDir, "logs"); + } + else + { + AYANOVA_LOG_PATH = Path.Combine(AYANOVA_LOG_PATH, "logs"); + } + + //(note, startup.cs ensures these folders exist via FileUtil because we need IHostingEnvironment) + //UserFiles + AYANOVA_FOLDER_USER_FILES = config.GetValue("AYANOVA_FOLDER_USER_FILES"); + + //BackupFiles + AYANOVA_FOLDER_BACKUP_FILES = config.GetValue("AYANOVA_FOLDER_BACKUP_FILES"); + #endregion server BASICS + + #region METRICS + //InfluxDB + bTemp = config.GetValue("AYANOVA_METRICS_USE_INFLUXDB"); + AYANOVA_METRICS_USE_INFLUXDB = (null == bTemp) ? false : (bool)bTemp; + + AYANOVA_METRICS_INFLUXDB_BASEURL = config.GetValue("AYANOVA_METRICS_INFLUXDB_BASEURL"); + AYANOVA_METRICS_INFLUXDB_BASEURL = string.IsNullOrWhiteSpace(AYANOVA_METRICS_INFLUXDB_BASEURL) ? "http://127.0.0.1:8086" : AYANOVA_METRICS_INFLUXDB_BASEURL; + + AYANOVA_METRICS_INFLUXDB_DBNAME = config.GetValue("AYANOVA_METRICS_INFLUXDB_DBNAME"); + AYANOVA_METRICS_INFLUXDB_DBNAME = string.IsNullOrWhiteSpace(AYANOVA_METRICS_INFLUXDB_DBNAME) ? "AyaNova" : AYANOVA_METRICS_INFLUXDB_DBNAME; + + AYANOVA_METRICS_INFLUXDB_CONSISTENCY = config.GetValue("AYANOVA_METRICS_INFLUXDB_CONSISTENCY"); + //No default value, if it's null or empty or whitespace then it won't be set + + AYANOVA_METRICS_INFLUXDB_USERNAME = config.GetValue("AYANOVA_METRICS_INFLUXDB_USERNAME"); + AYANOVA_METRICS_INFLUXDB_USERNAME = string.IsNullOrWhiteSpace(AYANOVA_METRICS_INFLUXDB_USERNAME) ? "root" : AYANOVA_METRICS_INFLUXDB_USERNAME; + + AYANOVA_METRICS_INFLUXDB_PASSWORD = config.GetValue("AYANOVA_METRICS_INFLUXDB_PASSWORD"); + AYANOVA_METRICS_INFLUXDB_PASSWORD = string.IsNullOrWhiteSpace(AYANOVA_METRICS_INFLUXDB_PASSWORD) ? "root" : AYANOVA_METRICS_INFLUXDB_PASSWORD; + + AYANOVA_METRICS_INFLUXDB_RETENSION_POLICY = config.GetValue("AYANOVA_METRICS_INFLUXDB_RETENSION_POLICY"); + //No default value, if it's null or empty or whitespace then it won't be set + + bTemp = config.GetValue("AYANOVA_METRICS_INFLUXDB_CREATE_DATABASE_IF_NOT_EXISTS"); + AYANOVA_METRICS_INFLUXDB_CREATE_DATABASE_IF_NOT_EXISTS = (null == bTemp) ? true : (bool)bTemp; + #endregion + + } + + + //Fetch first url from list of urls (used by generator) + internal static string FirstOfAyaNovaUseUrls + { + get + { + if (string.IsNullOrWhiteSpace(AYANOVA_USE_URLS)) + { return null; } + + if (!AYANOVA_USE_URLS.Contains(";")) + { + return AYANOVA_USE_URLS.Replace("*", "localhost"); + } + var s = AYANOVA_USE_URLS.Split(';'); + return s[0].Replace("*", "localhost"); + + } + } + + }//eoc + + +}//eons \ No newline at end of file diff --git a/server/AyaNova/util/ServiceProviderProvider.cs b/server/AyaNova/util/ServiceProviderProvider.cs new file mode 100644 index 00000000..1c1106f7 --- /dev/null +++ b/server/AyaNova/util/ServiceProviderProvider.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using AyaNova.Models; + +namespace AyaNova.Util +{ + /// + /// Shared service provider for static classes + /// + internal static class ServiceProviderProvider + { + private static IServiceProvider _provider; + //CALL IT LIKE THIS: + // ApiServerState apiServerState = (ApiServerState)ServiceProviderProvider.Provider.GetService(typeof(ApiServerState)); + /* + or is it like this?? + using (IServiceScope scope = provider.CreateScope()) + { + AyContext ct = scope.ServiceProvider.GetRequiredService(); + ApiServerState serverState = scope.ServiceProvider.GetRequiredService(); + */ + + + internal static IServiceProvider Provider + { + get + { + return _provider; + } + set + { + _provider = value; + } + + } + + + internal static IServiceScope Scope + { + get + { + return Provider.CreateScope(); + } + } + + + internal static AyContext DBContext + { + get + { + return Scope.ServiceProvider.GetRequiredService(); + } + } + + + + } +} \ No newline at end of file diff --git a/server/AyaNova/util/StringUtil.cs b/server/AyaNova/util/StringUtil.cs new file mode 100644 index 00000000..79ceb658 --- /dev/null +++ b/server/AyaNova/util/StringUtil.cs @@ -0,0 +1,96 @@ +using System; + +namespace AyaNova.Util +{ + + + internal static class StringUtil + { + + /// + /// Extract string between tokens + /// + /// + /// + /// + /// + public static string Extract(string s, string openTag, string closeTag) + { + int startIndex = s.IndexOf(openTag); + if (startIndex == -1) + throw new System.IndexOutOfRangeException("ExtractString->Error: open tag not found"); + startIndex += openTag.Length; + + int endIndex = s.IndexOf(closeTag, startIndex); + if (endIndex == -1) + throw new System.IndexOutOfRangeException("ExtractString->Error: closing tag not found"); + return s.Substring(startIndex, endIndex - startIndex); + } + + + + /// + /// Trim a string if necessary + /// + /// + /// + /// + public static string MaxLength(string s, int maxLength) + { + if (s.Length > maxLength) + s = s.Substring(0, maxLength); + return s; + } + + + /// + /// mask the exact ip address by substituting the last position of the address with XXX + /// Works with v6 or v4 addresses as strings + /// + /// + /// + public static string MaskIPAddress(string sIP) + { + //My test station ip address!? + //"::ffff:127.0.0.1" + //weird dual format, new method that covers both v4 and v4 inside v6 format + if(sIP.Contains(".")) + { + //new algorithm, replace anything after last period with an xxx + var ret=sIP.Substring(0,sIP.LastIndexOf("."))+".xxx"; + return ret; + + } + + + //8 groups IPV6 Address format + if (sIP.Contains(":")) + { + + sIP=sIP.Replace("::",":0:");//rehydrate "compressed" addresses + var segs = sIP.Split(':'); + if (segs.Length < 7) + return "UNRECOGNIZED V6 IP ADDRESS FORMAT"; + else + return segs[0] + ":" + segs[1] + ":" + segs[2] + ":" + segs[3] + ":" + segs[4] + ":" + segs[5] + ":" + segs[6] + ":" + segs[7] + ":xxxx"; + } + + // //4 groups IPV4 Address format + // if (sIP.Contains(".")) + // { + // //8 groups IPV6 Address format + // var segs = sIP.Split('.'); + // if (segs.Length < 3) + // return "UNRECOGNIZED V4 IP ADDRESS FORMAT"; + // else + // return segs[0] + "." + segs[1] + "." + segs[2] + ".xxx"; + // } + + return "UNRECOGNIZED IP ADDDRESS FORMAT"; + } + + + + }//eoc + +}//eons \ No newline at end of file diff --git a/server/AyaNova/wwwroot/api/sw.css b/server/AyaNova/wwwroot/api/sw.css new file mode 100644 index 00000000..235baf58 --- /dev/null +++ b/server/AyaNova/wwwroot/api/sw.css @@ -0,0 +1,6 @@ +#logo { + display: none; +} +.swagger-section #header { + background-color: #f90; +} diff --git a/server/AyaNova/wwwroot/index.htm b/server/AyaNova/wwwroot/index.htm new file mode 100644 index 00000000..35cef4c7 --- /dev/null +++ b/server/AyaNova/wwwroot/index.htm @@ -0,0 +1,59 @@ + + + + + + + + + + + +
+ + + + + + + +
+ + + + \ No newline at end of file diff --git a/startinflux.bat b/startinflux.bat new file mode 100644 index 00000000..0f25262b --- /dev/null +++ b/startinflux.bat @@ -0,0 +1,4 @@ +docker run -d --name docker-influxdb-grafana -p 3003:3003 -p 3004:8083 -p 8086:8086 -p 22022:22 philhawthorne/docker-influxdb-grafana:latest +rem -v /path/for/influxdb:/var/lib/influxdb \ +rem -v /path/for/grafana:/var/lib/grafana \ + \ No newline at end of file diff --git a/startsql.bat b/startsql.bat new file mode 100644 index 00000000..9a49e06d --- /dev/null +++ b/startsql.bat @@ -0,0 +1 @@ +docker start dock-pg10 dock-pgadmin diff --git a/startsql.sh b/startsql.sh new file mode 100644 index 00000000..9b4ea124 --- /dev/null +++ b/startsql.sh @@ -0,0 +1,2 @@ +#!/bin/bash +docker start dock-pg10 dock-pgadmin diff --git a/test/raven-integration/.vscode/launch.json b/test/raven-integration/.vscode/launch.json new file mode 100644 index 00000000..8a3f0fde --- /dev/null +++ b/test/raven-integration/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + // Use IntelliSense to find out which attributes exist for C# debugging + // Use hover for the description of the existing attributes + // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md + "version": "0.2.0", + "configurations": [ + { + "name": ".NET Core Launch (console)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/bin/Debug/netcoreapp2.0/raven-integration.dll", + "args": [], + "cwd": "${workspaceFolder}", + // For more information about the 'console' field, see https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md#console-terminal-window + "console": "internalConsole", + "stopAtEntry": false, + "internalConsoleOptions": "openOnSessionStart" + }, + { + "name": ".NET Core Attach", + "type": "coreclr", + "request": "attach", + "processId": "${command:pickProcess}" + } + ] +} \ No newline at end of file diff --git a/test/raven-integration/.vscode/settings.json b/test/raven-integration/.vscode/settings.json new file mode 100644 index 00000000..c2aabc7d --- /dev/null +++ b/test/raven-integration/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "editor.codeLens": true +} \ No newline at end of file diff --git a/test/raven-integration/.vscode/tasks.json b/test/raven-integration/.vscode/tasks.json new file mode 100644 index 00000000..51afd6ad --- /dev/null +++ b/test/raven-integration/.vscode/tasks.json @@ -0,0 +1,15 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "taskName": "build", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/raven-integration.csproj" + ], + "problemMatcher": "$msCompile" + } + ] +} \ No newline at end of file diff --git a/test/raven-integration/ApiResponse.cs b/test/raven-integration/ApiResponse.cs new file mode 100644 index 00000000..b6269018 --- /dev/null +++ b/test/raven-integration/ApiResponse.cs @@ -0,0 +1,13 @@ +using System.Net.Http; +using Newtonsoft.Json.Linq; + +namespace raven_integration +{ + public class ApiResponse + { + public HttpResponseMessage HttpResponse {get;set;} + public JObject ObjectResponse {get;set;} + + + }//eoc +}//eons \ No newline at end of file diff --git a/test/raven-integration/ApiTextResponse.cs b/test/raven-integration/ApiTextResponse.cs new file mode 100644 index 00000000..6cad35a8 --- /dev/null +++ b/test/raven-integration/ApiTextResponse.cs @@ -0,0 +1,13 @@ +using System.Net.Http; +using Newtonsoft.Json.Linq; + +namespace raven_integration +{ + public class ApiTextResponse + { + public HttpResponseMessage HttpResponse {get;set;} + public string TextResponse {get;set;} + + + }//eoc +}//eons \ No newline at end of file diff --git a/test/raven-integration/Attachments/AttachmentTest.cs b/test/raven-integration/Attachments/AttachmentTest.cs new file mode 100644 index 00000000..bfed5f4a --- /dev/null +++ b/test/raven-integration/Attachments/AttachmentTest.cs @@ -0,0 +1,182 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Net.Http.Headers; +using System.IO; + +namespace raven_integration +{ + //https://stackoverflow.com/questions/17725882/testing-asp-net-web-api-multipart-form-data-file-upload + public class AttachmentTest + { + /// + /// test attach CRUD + /// + [Fact] + public async void AttachmentUploadDownloadDeleteShouldWork() + { + + ////////////////////////////////////////// + //// Upload the files + MultipartFormDataContent formDataContent = new MultipartFormDataContent(); + + //Form data like the bizobject type and id + formDataContent.Add(new StringContent("2"), name: "AttachToObjectType"); + formDataContent.Add(new StringContent("1"), name: "AttachToObjectId"); + //or if testing non-existant this is probably safe: long.MaxValue + + + StreamContent file1 = new StreamContent(File.OpenRead($"{Util.TEST_DATA_FOLDER}\\test.png")); + file1.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + file1.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data"); + file1.Headers.ContentDisposition.FileName = "test.png"; + formDataContent.Add(file1); + StreamContent file2 = new StreamContent(File.OpenRead($"{Util.TEST_DATA_FOLDER}\\test.zip")); + file2.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + file2.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data"); + file2.Headers.ContentDisposition.FileName = "test.zip"; + formDataContent.Add(file2); + + //create via inventory full test user as attachments use the role of the object attaching to + ApiResponse a = await Util.PostFormDataAsync("Attachment", formDataContent, await Util.GetTokenAsync("InventoryFull")); + + + Util.ValidateDataReturnResponseOk(a); + + + long lTestPngAttachmentId = a.ObjectResponse["result"][0]["id"].Value(); + long lTestZipAttachmentId = a.ObjectResponse["result"][1]["id"].Value(); + + //saw negative values on a db issue that I corrected (I think) + //Keeping these in case it arises again, if it does, see log, it's a db error with async issue of some kind + lTestPngAttachmentId.Should().BePositive(); + lTestZipAttachmentId.Should().BePositive(); + + ////////////////////////////////////////// + //// DOWNLOAD: Get the file attachment + + //Get the inventoryfull account download token + // { + // "result": { + // "dlkey": "w7iE1cXF8kOxo8eomd1r8A", + // "expires": "2018-04-25T23:45:39.05665" + // } + // } + a = await Util.GetAsync("Attachment/DownloadToken", await Util.GetTokenAsync("InventoryFull")); + Util.ValidateDataReturnResponseOk(a); + string downloadToken = a.ObjectResponse["result"]["dlkey"].Value(); + + //now get the file https://rockfish.ayanova.com/api/rfcaseblob/download/248?dlkey=9O2eDAAlZ0Wknj19SBK2iA + var dlresponse = await Util.DownloadFileAsync("Attachment/Download/" + lTestZipAttachmentId.ToString() + "?dlkey=" + downloadToken, await Util.GetTokenAsync("InventoryFull")); + + //ensure it's the zip file we expected + dlresponse.Content.Headers.ContentDisposition.FileName.Should().Be("test.zip"); + dlresponse.Content.Headers.ContentLength.Should().BeGreaterThan(2000); + + + ////////////////////////////////////////// + //// DELETE: Delete the file attachments + ApiResponse d = await Util.DeleteAsync("Attachment/" + lTestPngAttachmentId.ToString(), await Util.GetTokenAsync("InventoryFull")); + Util.ValidateHTTPStatusCode(d, 204); + + d = await Util.DeleteAsync("Attachment/" + lTestZipAttachmentId.ToString(), await Util.GetTokenAsync("InventoryFull")); + Util.ValidateHTTPStatusCode(d, 204); + + + } + + + + /// + /// test no rights + /// + [Fact] + public async void NoRightsTest() + { + + MultipartFormDataContent formDataContent = new MultipartFormDataContent(); + + formDataContent.Add(new StringContent("2"), name: "AttachToObjectType"); + formDataContent.Add(new StringContent("1"), name: "AttachToObjectId"); + + StreamContent file1 = new StreamContent(File.OpenRead($"{Util.TEST_DATA_FOLDER}\\test.png")); + file1.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + file1.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data"); + file1.Headers.ContentDisposition.FileName = "test.png"; + formDataContent.Add(file1); + + //ERROR CONDITION: BizAdminLimited user should not be able to attach a file to a widget + ApiResponse a = await Util.PostFormDataAsync("Attachment", formDataContent, await Util.GetTokenAsync("BizAdminLimited")); + + //2004 unauthorized + Util.ValidateErrorCodeResponse(a, 2004, 401); + + } + + + /// + /// test not attachable + /// + [Fact] + public async void UnattachableTest() + { + MultipartFormDataContent formDataContent = new MultipartFormDataContent(); + + //Form data bizobject type and id + + //HERE IS THE ERROR CONDITION: LICENSE TYPE OBJECT WHICH IS UNATTACHABLE + formDataContent.Add(new StringContent("5"), name: "AttachToObjectType"); + formDataContent.Add(new StringContent("1"), name: "AttachToObjectId"); + + StreamContent file1 = new StreamContent(File.OpenRead($"{Util.TEST_DATA_FOLDER}\\test.png")); + file1.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + file1.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data"); + file1.Headers.ContentDisposition.FileName = "test.png"; + formDataContent.Add(file1); + ApiResponse a = await Util.PostFormDataAsync("Attachment", formDataContent, await Util.GetTokenAsync("InventoryFull")); + + //2203 unattachable object + Util.ValidateErrorCodeResponse(a, 2203, 400); + + } + + + /// + /// test bad object values + /// + [Fact] + public async void BadObject() + { + MultipartFormDataContent formDataContent = new MultipartFormDataContent(); + + //Form data like the bizobject type and id + formDataContent.Add(new StringContent("2"), name: "AttachToObjectType"); + + //HERE IS THE ERROR CONDITION, A NON EXISTENT ID VALUE FOR THE WIDGET + formDataContent.Add(new StringContent(long.MaxValue.ToString()), name: "AttachToObjectId");//non-existent widget + + StreamContent file1 = new StreamContent(File.OpenRead($"{Util.TEST_DATA_FOLDER}\\test.png")); + file1.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + file1.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data"); + file1.Headers.ContentDisposition.FileName = "test.png"; + formDataContent.Add(file1); + ApiResponse a = await Util.PostFormDataAsync("Attachment", formDataContent, await Util.GetTokenAsync("InventoryFull")); + + //2203 invalid attachment object + Util.ValidateErrorCodeResponse(a, 2203, 400); + + } + + + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Authentication/Auth.cs b/test/raven-integration/Authentication/Auth.cs new file mode 100644 index 00000000..9e9233f2 --- /dev/null +++ b/test/raven-integration/Authentication/Auth.cs @@ -0,0 +1,36 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; + +namespace raven_integration +{ + + public class Auth + { + /// + /// + /// + [Fact] + public async void BadLoginShouldNotWork() + { + //Expect status code 401 and result: + // {{ + // "error": { + // "code": "2003", + // "message": "Authentication failed" + // } + // }} + + dynamic d = new JObject(); + d.login = "BOGUS"; + d.password = "ACCOUNT"; + ApiResponse a = await Util.PostAsync("Auth", null, d.ToString()); + Util.ValidateErrorCodeResponse(a,2003,401); + } + + //================================================== + + }//eoc +}//eons + diff --git a/test/raven-integration/AyaType/AyaType.cs b/test/raven-integration/AyaType/AyaType.cs new file mode 100644 index 00000000..728e55e6 --- /dev/null +++ b/test/raven-integration/AyaType/AyaType.cs @@ -0,0 +1,36 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class AyaType + { + + /// + /// + /// + [Fact] + public async void AyaTypeListShouldWork() + { + ApiResponse a = await Util.GetAsync("AyaType"); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + //there should be at least 8 of them (at time of writing late March 2018) + ((JArray)a.ObjectResponse["result"]).Count.Should().BeGreaterOrEqualTo(8); + + //Number 2 is widget and list is zero based so confirm: + a.ObjectResponse["result"][2]["id"].Value().Should().Be(2); + a.ObjectResponse["result"][2]["name"].Value().Should().Be("Widget [Attachable] [Taggable]"); + + } + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/ImportV7/ImportV7.cs b/test/raven-integration/ImportV7/ImportV7.cs new file mode 100644 index 00000000..b9661824 --- /dev/null +++ b/test/raven-integration/ImportV7/ImportV7.cs @@ -0,0 +1,57 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; +using System.Net.Http; +using System.Net.Http.Headers; +using System.IO; + + +namespace raven_integration +{ + + public class ImportV7 + { + //================================================== + /// + /// Test Importv7 stuff + /// + [Fact] + public async void ImportV7FileRoutesShouldWork() + { + + string UploadFileName = "ayanova.data.dump.xxx.zip"; + + ////////////////////////////////////////// + //// Upload the files + MultipartFormDataContent formDataContent = new MultipartFormDataContent(); + + StreamContent file1 = new StreamContent(File.OpenRead($"{Util.TEST_DATA_FOLDER}\\{UploadFileName}")); + file1.Headers.ContentType = new MediaTypeHeaderValue("application/zip"); + file1.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data"); + file1.Headers.ContentDisposition.FileName = UploadFileName; + formDataContent.Add(file1); + + ApiResponse a = await Util.PostFormDataAsync("ImportAyaNova7", formDataContent, await Util.GetTokenAsync("OpsAdminFull")); + Util.ValidateDataReturnResponseOk(a); + + string importFileName = a.ObjectResponse["result"][0].Value(); + importFileName.Should().Be(UploadFileName); + + + ////////////////////////////////////////// + //// DELETE: Delete the file + ApiResponse d = await Util.DeleteAsync($"ImportAyaNova7/{UploadFileName}", await Util.GetTokenAsync("OpsAdminFull")); + Util.ValidateHTTPStatusCode(d, 204); + + + + } + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/JobOperations/JobOperations.cs b/test/raven-integration/JobOperations/JobOperations.cs new file mode 100644 index 00000000..c3af2e4e --- /dev/null +++ b/test/raven-integration/JobOperations/JobOperations.cs @@ -0,0 +1,59 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class JobOperations + { + + /// + /// + /// + [Fact] + public async void TestJobShouldSubmit() + { + ApiResponse a = await Util.GetAsync("Widget/TestWidgetJob", await Util.GetTokenAsync("OpsAdminFull")); + //Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 202); + //should return something like this: + /* + { + "testJobId": 4 + } + */ + + String jobId = a.ObjectResponse["jobId"].Value(); + + //Get a list of operations + a = await Util.GetAsync("JobOperations", await Util.GetTokenAsync("OpsAdminFull")); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + + + + //there should be at least 1 + ((JArray)a.ObjectResponse["result"]).Count.Should().BeGreaterOrEqualTo(1); + + //See if our job is in there + bool bFound=false; + foreach(JToken t in a.ObjectResponse["result"]) + { + if(t["gId"].Value()==jobId) + bFound=true; + } + bFound.Should().BeTrue(); + + + + } + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Locale/Locale.cs b/test/raven-integration/Locale/Locale.cs new file mode 100644 index 00000000..200d0ef8 --- /dev/null +++ b/test/raven-integration/Locale/Locale.cs @@ -0,0 +1,164 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Collections.Concurrent; +namespace raven_integration +{ + + public class Locale + { + + /* + + ImportLocale(ct, ResourceFolderPath, "en"); - id 1 + ImportLocale(ct, ResourceFolderPath, "es"); - id 2 + ImportLocale(ct, ResourceFolderPath, "fr"); - id 3 + ImportLocale(ct, ResourceFolderPath, "de"); - id 4 + */ + + [Fact] + public async void LocalePickListWorks() + { + //Get all + ApiResponse a = await Util.GetAsync("Locale/picklist", await Util.GetTokenAsync("ClientLimited"));//lowest level test user because there are no limits on this route except to be authenticated + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + //there should be at least 4 of them as there are 4 stock locales + ((JArray)a.ObjectResponse["result"]).Count.Should().BeGreaterThan(3); + } + + + [Fact] + public async void GetFullLocaleWorks() + { + //Get all + ApiResponse a = await Util.GetAsync("Locale/1", await Util.GetTokenAsync("ClientLimited"));//lowest level test user because there are no limits on this route except to be authenticated + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + //there should be dozens of keys but at times there might only be a few during development so at least verify there is more than one + ((JArray)a.ObjectResponse["result"]["localeItems"]).Count.Should().BeGreaterThan(0); + } + + + [Fact] + public async void GetSubsetWorks() + { + /* + { + "localeId": 0, + "keys": [ + "string" + ] + } + */ + + List keys = new List(); + keys.AddRange(new string[] { "AddressType", "ClientName", "RateName", "WorkorderService" }); + dynamic d = new JObject(); + d.localeId = 1; + d.keys = JToken.FromObject(keys); + + ApiResponse a = await Util.PostAsync("Locale/subset", await Util.GetTokenAsync("ClientLimited"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + //there should be dozens of keys but at times there might only be a few during development so at least verify there is more than one + ((JArray)a.ObjectResponse["result"]).Count.Should().Be(4); + } + + + [Fact] + public async void DuplicateUpdateAndDeleteWorks() + { + /* + { + "id": 1, + "name": "CustomTest1" + } + */ + + //DUPLICATE + dynamic d = new JObject(); + d.id = 1; + d.name = Util.Uniquify("INTEGRATION-TEST-LOCALE"); + + ApiResponse a = await Util.PostAsync("Locale/Duplicate", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 201); + //verify the object returned is as expected + a.ObjectResponse["result"]["name"].Value().Should().Be(d.name.ToString()); + a.ObjectResponse["result"]["stock"].Value().Should().Be(false); + a.ObjectResponse["result"]["id"].Value().Should().BeGreaterThan(4); + a.ObjectResponse["result"]["concurrencyToken"].Value().Should().BeGreaterThan(0); + ((JArray)a.ObjectResponse["result"]["localeItems"]).Count.Should().BeGreaterThan(0); + + long NewId = a.ObjectResponse["result"]["id"].Value(); + + //UPDATE + //Update locale name + + /* + + { + "id": 10, + "newText": "What the hell?", + "concurrencyToken": 25174 + } + + */ + dynamic d2 = new JObject(); + d2.id = NewId; + d2.newText = Util.Uniquify("INTEGRATION-TEST-LOCALE NAME UPDATE"); + d2.concurrencyToken = a.ObjectResponse["result"]["concurrencyToken"].Value(); + ApiResponse PUTTestResponse = await Util.PutAsync("Locale/UpdateLocaleName", await Util.GetTokenAsync("BizAdminFull"), d2.ToString()); + Util.ValidateHTTPStatusCode(PUTTestResponse, 200); + + + ApiResponse checkPUTWorked = await Util.GetAsync("Locale/" + NewId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateNoErrorInResponse(checkPUTWorked); + checkPUTWorked.ObjectResponse["result"]["name"].Value().Should().Be(d2.newText.ToString()); + //uint concurrencyToken = PUTTestResponse.ObjectResponse["result"]["concurrencyToken"].Value(); + + + //Update locale key + var FirstLocaleKey = ((JArray)a.ObjectResponse["result"]["localeItems"])[0]; + long UpdatedLocaleKeyId = FirstLocaleKey["id"].Value(); + d2.id = UpdatedLocaleKeyId; + d2.newText = Util.Uniquify("INTEGRATION-TEST-LOCALEITEM DISPLAY UPDATE"); + d2.concurrencyToken = FirstLocaleKey["concurrencyToken"].Value(); + + string UpdatedLocaleKey = FirstLocaleKey["key"].Value(); + + PUTTestResponse = await Util.PutAsync("Locale/UpdateLocaleItemDisplayText", await Util.GetTokenAsync("BizAdminFull"), d2.ToString()); + Util.ValidateHTTPStatusCode(PUTTestResponse, 200); + + List keys = new List(); + keys.AddRange(new string[] { UpdatedLocaleKey }); + dynamic d3 = new JObject(); + d3.localeId = NewId; + d3.keys = JToken.FromObject(keys); + + checkPUTWorked = await Util.PostAsync("Locale/subset", await Util.GetTokenAsync("ClientLimited"), d3.ToString()); + Util.ValidateDataReturnResponseOk(checkPUTWorked); + Util.ValidateHTTPStatusCode(checkPUTWorked, 200); + ((JArray)checkPUTWorked.ObjectResponse["result"]).Count.Should().Be(1); + var FirstLocaleKeyUpdated = ((JArray)checkPUTWorked.ObjectResponse["result"])[0]; + + FirstLocaleKeyUpdated["value"].Value().Should().Be(d2.newText.ToString()); + + //DELETE + + a = await Util.DeleteAsync("Locale/" + NewId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + + + } + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/LogFiles/LogFiles.cs b/test/raven-integration/LogFiles/LogFiles.cs new file mode 100644 index 00000000..2ddb2b15 --- /dev/null +++ b/test/raven-integration/LogFiles/LogFiles.cs @@ -0,0 +1,31 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class LogFiles + { + + + + /// + /// + /// + [Fact] + public async void MostRecentLogShouldFetch() + { + ApiTextResponse t = await Util.GetTextResultAsync("LogFiles/log-ayanova.txt", await Util.GetTokenAsync("OpsAdminFull")); + Util.ValidateHTTPStatusCode(t, 200); + t.TextResponse.Should().Contain("|INFO|");//assumes any log will have at least one INFO log item in it + + } + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Metrics/Metrics.cs b/test/raven-integration/Metrics/Metrics.cs new file mode 100644 index 00000000..b3c4dd6c --- /dev/null +++ b/test/raven-integration/Metrics/Metrics.cs @@ -0,0 +1,51 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class Metrics + { + + /// + /// + /// + [Fact] + public async void TextMetricsShouldFetch() + { + ApiTextResponse t = await Util.GetTextResultAsync("Metrics/TextSnapShot", await Util.GetTokenAsync("OpsAdminFull")); + + Util.ValidateHTTPStatusCode(t, 200); + + t.TextResponse.Should().StartWith("# TIMESTAMP:"); + + } + + + + /// + /// + /// + [Fact] + public async void JsonMetricsShouldFetch() + { + ApiResponse a = await Util.GetAsync("Metrics/JsonSnapShot", await Util.GetTokenAsync("OpsAdminFull")); + + Util.ValidateDataReturnResponseOk(a); + + a.ObjectResponse["result"]["timestamp"].Should().NotBeNull(); + ((JArray)a.ObjectResponse["result"]["contexts"]).Count.Should().BeGreaterThan(0); + + + + } + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Privacy/Privacy.cs b/test/raven-integration/Privacy/Privacy.cs new file mode 100644 index 00000000..5457b089 --- /dev/null +++ b/test/raven-integration/Privacy/Privacy.cs @@ -0,0 +1,28 @@ +using Xunit; +using FluentAssertions; + +namespace raven_integration +{ + + public class Privacy + { + + + + /// + /// + /// + [Fact] + public async void LogShouldNotContainPrivateData() + { + ApiResponse a = await Util.GetAsync("AyaType", await Util.GetTokenAsync("TEST_PRIVACY_USER_ACCOUNT")); + ApiTextResponse t = await Util.GetTextResultAsync("LogFiles/log-ayanova.txt", await Util.GetTokenAsync("TEST_PRIVACY_USER_ACCOUNT")); + Util.ValidateHTTPStatusCode(t, 200); + t.TextResponse.Should().NotContain("TEST_PRIVACY_USER_ACCOUNT"); + + } + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/ServerState/ServerStateTest.cs b/test/raven-integration/ServerState/ServerStateTest.cs new file mode 100644 index 00000000..6344d6ca --- /dev/null +++ b/test/raven-integration/ServerState/ServerStateTest.cs @@ -0,0 +1,79 @@ +using Xunit; + +namespace raven_integration +{ + + public class ServerStateTest + { + + + // ApiFixture fixture; + + // public ServerStateTest(ApiFixture _fixture) + // { + // this.fixture = _fixture; + // } + + + + /// + /// Test get state + /// + [Fact] + public async void ServerStateShouldReturnOk() + { + ApiResponse a = await Util.GetAsync("ServerState"); + Util.ValidateDataReturnResponseOk(a); + } + + + + + //can't test this because it fucks up other tests and the fixture + + // /// + // /// Test set state + // /// + // [Fact] + // public async void ServerStateShouldSet() + // { + + // /*{ + // "serverState": "open" + // } */ + + + // //open + // dynamic dd = new JObject(); + // dd.serverState = "open"; + + // ApiResponse aa = await Util.PostAsync("ServerState", fixture.ManagerAuthToken, dd.ToString()); + // Util.ValidateHTTPStatusCode(aa, 204); + + // //close + // dynamic d = new JObject(); + // d.serverState = "closed"; + + // ApiResponse a = await Util.PostAsync("ServerState", fixture.ManagerAuthToken, d.ToString()); + // Util.ValidateHTTPStatusCode(a, 204); + + // //open + // dynamic ddd = new JObject(); + // dd.serverState = "open"; + + // ApiResponse aaa = await Util.PostAsync("ServerState", fixture.ManagerAuthToken, ddd.ToString()); + // Util.ValidateHTTPStatusCode(aaa, 204); + + + + // } + + + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Tags/TagCrud.cs b/test/raven-integration/Tags/TagCrud.cs new file mode 100644 index 00000000..b2d91382 --- /dev/null +++ b/test/raven-integration/Tags/TagCrud.cs @@ -0,0 +1,106 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class TagCrud + { + + /// + /// Test all CRUD routes for a widget + /// + [Fact] + public async void CRUD() + { + /* + { + "name": "TestTag" + } + */ + + //CREATE + dynamic w1 = new JObject(); + w1.name = Util.Uniquify("TeStTaG"); + + + ApiResponse a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), w1.ToString()); + Util.ValidateDataReturnResponseOk(a); + long tagId = a.ObjectResponse["result"]["id"].Value(); + string tagName = a.ObjectResponse["result"]["name"].Value(); + tagName.Should().StartWith("testtag"); + + + //RETRIEVE + /* + { + "result": { + "id": 24, + "created": "2018-03-28T21:07:41.9703503Z", + "concurrencyToken": 9502, + "ownerId": 1, + "name": "یونی‌کُد چیست؟" + } + } + */ + //Get one + a = await Util.GetAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateDataReturnResponseOk(a); + a.ObjectResponse["result"]["name"].Value().Should().StartWith("testtag"); + + //UPDATE + + //PUT + w1.Id = tagId; + w1.name = Util.Uniquify("PutTestTag"); + w1.created = DateTime.UtcNow.ToString("s", System.Globalization.CultureInfo.InvariantCulture); + w1.concurrencyToken = a.ObjectResponse["result"]["concurrencyToken"].Value(); + w1.OwnerId = 1L; + + + ApiResponse PUTTestResponse = await Util.PutAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull"), w1.ToString()); + Util.ValidateHTTPStatusCode(PUTTestResponse, 200); + + //check PUT worked + ApiResponse checkPUTWorked = await Util.GetAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateNoErrorInResponse(checkPUTWorked); + checkPUTWorked.ObjectResponse["result"]["name"].Value().Should().Be(w1.name.ToString().ToLowerInvariant()); + uint concurrencyToken = PUTTestResponse.ObjectResponse["result"]["concurrencyToken"].Value(); + + //PATCH + var newName = Util.Uniquify("PatchUpdate"); + string patchJson = "[{\"value\": \"" + newName + "\",\"path\": \"/name\",\"op\": \"replace\"}]"; + ApiResponse PATCHTestResponse = await Util.PatchAsync("Tag/" + tagId.ToString() + "/" + concurrencyToken.ToString(), await Util.GetTokenAsync("BizAdminFull"), patchJson); + Util.ValidateHTTPStatusCode(PATCHTestResponse, 200); + + //check PATCH worked + ApiResponse checkPATCHWorked = await Util.GetAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateNoErrorInResponse(checkPATCHWorked); + checkPATCHWorked.ObjectResponse["result"]["name"].Value().Should().Be(newName.ToLowerInvariant()); + + // //DELETE + ApiResponse DELETETestResponse = await Util.DeleteAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(DELETETestResponse, 204); + } + + + + + + + + + + + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Tags/TagLists.cs b/test/raven-integration/Tags/TagLists.cs new file mode 100644 index 00000000..db770878 --- /dev/null +++ b/test/raven-integration/Tags/TagLists.cs @@ -0,0 +1,82 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class TagLists + { + + /// + /// + /// + [Fact] + public async void PickListSearchRouteShouldWorkAsExpected() + { + List createdTagList = new List(); + + //CREATE + dynamic d = new JObject(); + + + d.name = Util.Uniquify("Apple"); + ApiResponse a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("Applesauce"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("Apple-Tar-Tar"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("Apple-Tini"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("Apple-Pie"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("AppleJack"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + + //Get all + a = await Util.GetAsync("Tag/picklist?Offset=1&Limit=25&q=ApPlE", await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + //there should be at least 5 of them + ((JArray)a.ObjectResponse["result"]).Count.Should().BeGreaterThan(4); + + + //Delete them all here (just a cleanup) + foreach (long lId in createdTagList) + { + a = await Util.DeleteAsync("Tag/" + lId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + } + + + } + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Tags/TagMapOps.cs b/test/raven-integration/Tags/TagMapOps.cs new file mode 100644 index 00000000..f2bc0f39 --- /dev/null +++ b/test/raven-integration/Tags/TagMapOps.cs @@ -0,0 +1,274 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class TagMapOps + { + + /// + /// Test tagmap map unmap and etc + /// + [Fact] + public async void MapUnmapShouldWork() + { + /* + { + "name": "TestTag" + } + */ + + //CREATE TAG + dynamic d = new JObject(); + d.name = Util.Uniquify("test-tag-4-widget"); + + + ApiResponse a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long tagId = a.ObjectResponse["result"]["id"].Value(); + + + //CREATE WIDGET + dynamic w = new JObject(); + w.name = Util.Uniquify("WIDGET_TAG"); + w.created = DateTime.Now.ToString(); + w.dollarAmount = 1.11m; + w.active = true; + w.roles = 0; + + a = await Util.PostAsync("Widget", await Util.GetTokenAsync("BizAdminFull"), w.ToString()); + Util.ValidateDataReturnResponseOk(a); + long widgetId = a.ObjectResponse["result"]["id"].Value(); + + //CREATE TAGMAP (tag the widget) + /* + { + "tagId": 0, + "tagToObjectId": 0, + "tagToObjectType": 0 + } + */ + dynamic tm = new JObject(); + tm.tagId = tagId; + tm.tagToObjectId = widgetId; + tm.tagToObjectType = 2;//widget + + + a = await Util.PostAsync("TagMap", await Util.GetTokenAsync("BizAdminFull"), tm.ToString()); + Util.ValidateDataReturnResponseOk(a); + long tagMapId = a.ObjectResponse["result"]["id"].Value(); + + //VERIFY TAGMAP + a = await Util.GetAsync("TagMap/" + tagMapId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateDataReturnResponseOk(a); + a.ObjectResponse["result"]["id"].Value().Should().Be(tagMapId); + + + //ATTEMPT TO DELETE TAG THAT HAS TAGMAP SHOULD FAIL with 2200 / 400 + a = await Util.DeleteAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateViolatesReferentialIntegrityError(a); + + //TODO: + //Test delete parent object deletes tagmaps (widget delete) + + + //DELETE TAGMAP + a = await Util.DeleteAsync("TagMap/" + tagMapId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + + //DELETE TAG + a = await Util.DeleteAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + + //DELETE WIDGET + a = await Util.DeleteAsync("Widget/" + widgetId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + } + + + + + /// + /// + /// + [Fact] + public async void TagMapShouldDeleteWhenParentDeletes() + { + /* + { + "name": "TestTag" + } + */ + + //CREATE TAG + dynamic d = new JObject(); + d.name = Util.Uniquify("test-unmap-tag-4-widget"); + + + ApiResponse a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long tagId = a.ObjectResponse["result"]["id"].Value(); + + + //CREATE WIDGET + dynamic w = new JObject(); + w.name = Util.Uniquify("WIDGET_TAG_AUTO_UNMAP"); + w.created = DateTime.Now.ToString(); + w.dollarAmount = 1.11m; + w.active = true; + w.roles = 0; + + a = await Util.PostAsync("Widget", await Util.GetTokenAsync("BizAdminFull"), w.ToString()); + Util.ValidateDataReturnResponseOk(a); + long widgetId = a.ObjectResponse["result"]["id"].Value(); + + //CREATE TAGMAP (tag the widget) + /* + { + "tagId": 0, + "tagToObjectId": 0, + "tagToObjectType": 0 + } + */ + dynamic tm = new JObject(); + tm.tagId = tagId; + tm.tagToObjectId = widgetId; + tm.tagToObjectType = 2;//widget + + + a = await Util.PostAsync("TagMap", await Util.GetTokenAsync("BizAdminFull"), tm.ToString()); + Util.ValidateDataReturnResponseOk(a); + long tagMapId = a.ObjectResponse["result"]["id"].Value(); + + //VERIFY TAGMAP + a = await Util.GetAsync("TagMap/" + tagMapId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateDataReturnResponseOk(a); + a.ObjectResponse["result"]["id"].Value().Should().Be(tagMapId); + + + //DELETE PARENT (WIDGET) + a = await Util.DeleteAsync("Widget/" + widgetId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + + //VERIFY TAGMAP GONE + a = await Util.GetAsync("TagMap/" + tagMapId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateResponseNotFound(a); + + + //////////////////////// + //CLEANUP + + //DELETE TAG + a = await Util.DeleteAsync("Tag/" + tagId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + + } + + + /// + /// + /// + [Fact] + public async void TagMapListForObject() + { + List createdTagList = new List(); + + //CREATE a bunch of tags + dynamic d = new JObject(); + + + d.name = Util.Uniquify("Red"); + ApiResponse a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("red-green"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("red-rum"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("red-dit"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + d.name = Util.Uniquify("red-tailed-hawk"); + a = await Util.PostAsync("Tag", await Util.GetTokenAsync("BizAdminFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + createdTagList.Add(a.ObjectResponse["result"]["id"].Value()); + + + //Create a widget + dynamic w = new JObject(); + w.name = Util.Uniquify("WIDGET_4_TAG_LIST_TEST"); + w.created = DateTime.Now.ToString(); + w.dollarAmount = 1.11m; + w.active = true; + w.roles = 0; + + a = await Util.PostAsync("Widget", await Util.GetTokenAsync("BizAdminFull"), w.ToString()); + Util.ValidateDataReturnResponseOk(a); + long widgetId = a.ObjectResponse["result"]["id"].Value(); + + + //Tag all the tags to the widget + dynamic tm = new JObject(); + foreach (long tagId in createdTagList) + { + tm.tagId = tagId; + tm.tagToObjectId = widgetId; + tm.tagToObjectType = 2;//widget + + a = await Util.PostAsync("TagMap", await Util.GetTokenAsync("BizAdminFull"), tm.ToString()); + Util.ValidateDataReturnResponseOk(a); + } + + //GET TAGS FOR WIDGET + dynamic tid = new JObject(); + tid.objectId = widgetId; + tid.objectType = 2;//widget + a = await Util.GetAsync("TagMap/TagsOnObject/", await Util.GetTokenAsync("BizAdminFull"), tid.ToString()); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + //there should be at least 5 of them + ((JArray)a.ObjectResponse["result"]).Count.Should().BeGreaterOrEqualTo(createdTagList.Count); + + //delete widget (which will delete tagmaps as well) + a = await Util.DeleteAsync("Widget/" + widgetId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + + + + //CLEANUP TAGS + foreach (long lId in createdTagList) + { + a = await Util.DeleteAsync("Tag/" + lId.ToString(), await Util.GetTokenAsync("BizAdminFull")); + Util.ValidateHTTPStatusCode(a, 204); + } + + + } + + + + + + + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Widget/WidgetCrud.cs b/test/raven-integration/Widget/WidgetCrud.cs new file mode 100644 index 00000000..53f38671 --- /dev/null +++ b/test/raven-integration/Widget/WidgetCrud.cs @@ -0,0 +1,227 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; + +namespace raven_integration +{ + + public class WidgetCrud + { + + /// + /// Test all CRUD routes for a widget + /// + [Fact] + public async void CRUD() + { + /* + { + "id": 0, + "name": "string", + "created": "2018-02-09T16:45:56.057Z", + "dollarAmount": 0, + "active": true, + "roles": 0 + } + */ + //CREATE + dynamic w1 = new JObject(); + w1.name = Util.Uniquify("First Test WIDGET"); + w1.created = DateTime.Now.ToString(); + w1.dollarAmount = 1.11m; + w1.active = true; + w1.roles = 0; + + ApiResponse r1 = await Util.PostAsync("Widget", await Util.GetTokenAsync("manager", "l3tm3in"), w1.ToString()); + Util.ValidateDataReturnResponseOk(r1); + long w1Id = r1.ObjectResponse["result"]["id"].Value(); + + + dynamic w2 = new JObject(); + w2.name = Util.Uniquify("Second Test WIDGET"); + w2.created = DateTime.Now.ToString(); + w2.dollarAmount = 2.22m; + w2.active = true; + w2.roles = 0; + + ApiResponse r2 = await Util.PostAsync("Widget", await Util.GetTokenAsync( "manager", "l3tm3in"), w2.ToString()); + Util.ValidateDataReturnResponseOk(r2); + long w2Id = r2.ObjectResponse["result"]["id"].Value(); + + + //RETRIEVE + + //Get one + ApiResponse r3 = await Util.GetAsync("Widget/" + w2Id.ToString(), await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateDataReturnResponseOk(r3); + r3.ObjectResponse["result"]["name"].Value().Should().Be(w2.name.ToString()); + + + + //UPDATE + //PUT + + //update w2id + w2.name = Util.Uniquify("UPDATED VIA PUT SECOND TEST WIDGET"); + w2.OwnerId = 1; + w2.concurrencyToken = r2.ObjectResponse["result"]["concurrencyToken"].Value(); + ApiResponse PUTTestResponse = await Util.PutAsync("Widget/" + w2Id.ToString(), await Util.GetTokenAsync( "manager", "l3tm3in"), w2.ToString()); + Util.ValidateHTTPStatusCode(PUTTestResponse, 200); + + //check PUT worked + ApiResponse checkPUTWorked = await Util.GetAsync("Widget/" + w2Id.ToString(), await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateNoErrorInResponse(checkPUTWorked); + checkPUTWorked.ObjectResponse["result"]["name"].Value().Should().Be(w2.name.ToString()); + uint concurrencyToken = PUTTestResponse.ObjectResponse["result"]["concurrencyToken"].Value(); + + //PATCH + var newName = Util.Uniquify("UPDATED VIA PATCH SECOND TEST WIDGET"); + string patchJson = "[{\"value\": \"" + newName + "\",\"path\": \"/name\",\"op\": \"replace\"}]"; + ApiResponse PATCHTestResponse = await Util.PatchAsync("Widget/" + w2Id.ToString() + "/" + concurrencyToken.ToString(), await Util.GetTokenAsync( "manager", "l3tm3in"), patchJson); + Util.ValidateHTTPStatusCode(PATCHTestResponse, 200); + + //check PATCH worked + ApiResponse checkPATCHWorked = await Util.GetAsync("Widget/" + w2Id.ToString(), await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateNoErrorInResponse(checkPATCHWorked); + checkPATCHWorked.ObjectResponse["result"]["name"].Value().Should().Be(newName); + + //DELETE + ApiResponse DELETETestResponse = await Util.DeleteAsync("Widget/" + w2Id.ToString(), await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateHTTPStatusCode(DELETETestResponse, 204); + } + + + + + + + /// + /// Test not found + /// + [Fact] + public async void GetNonExistentItemShouldError() + { + //Get non existant + //Should return status code 404, api error code 2010 + ApiResponse a = await Util.GetAsync("Widget/999999", await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateResponseNotFound(a); + } + + /// + /// Test bad modelstate + /// + [Fact] + public async void GetBadModelStateShouldError() + { + //Get non existant + //Should return status code 400, api error code 2200 and a first target in details of "id" + ApiResponse a = await Util.GetAsync("Widget/2q2", await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateBadModelStateResponse(a, "id"); + } + + + /// + /// Test server exception + /// + [Fact] + public async void ServerExceptionShouldErrorPropertly() + { + //Get non existant + //Should return status code 400, api error code 2200 and a first target in details of "id" + ApiResponse a = await Util.GetAsync("Widget/exception", await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateServerExceptionResponse(a); + } + + + + /// + /// Test server alt exception + /// + [Fact] + public async void ServerAltExceptionShouldErrorPropertly() + { + //Get non existant + //Should return status code 400, api error code 2200 and a first target in details of "id" + ApiResponse a = await Util.GetAsync("Widget/altexception", await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateServerExceptionResponse(a); + } + + + + + /// + /// + /// + [Fact] + public async void PutConcurrencyViolationShouldFail() + { + + //CREATE + + dynamic w2 = new JObject(); + w2.name = Util.Uniquify("PutConcurrencyViolationShouldFail"); + w2.created = DateTime.Now.ToString(); + w2.dollarAmount = 2.22m; + w2.active = true; + w2.roles = 0; + + ApiResponse r2 = await Util.PostAsync("Widget", await Util.GetTokenAsync( "manager", "l3tm3in"), w2.ToString()); + Util.ValidateDataReturnResponseOk(r2); + long w2Id = r2.ObjectResponse["result"]["id"].Value(); + uint OriginalConcurrencyToken = r2.ObjectResponse["result"]["concurrencyToken"].Value(); + + + + //UPDATE + //PUT + + w2.name = Util.Uniquify("PutConcurrencyViolationShouldFail UPDATE VIA PUT "); + w2.OwnerId = 1; + w2.concurrencyToken = OriginalConcurrencyToken - 1;//bad token + ApiResponse PUTTestResponse = await Util.PutAsync("Widget/" + w2Id.ToString(), await Util.GetTokenAsync( "manager", "l3tm3in"), w2.ToString()); + Util.ValidateConcurrencyError(PUTTestResponse); + + + } + + + + + /// + /// + /// + [Fact] + public async void PatchConcurrencyViolationShouldFail() + { + + //CREATE + + dynamic w2 = new JObject(); + w2.name = Util.Uniquify("PatchConcurrencyViolationShouldFail"); + w2.created = DateTime.Now.ToString(); + w2.dollarAmount = 2.22m; + w2.active = true; + w2.roles = 0; + + ApiResponse r2 = await Util.PostAsync("Widget", await Util.GetTokenAsync( "manager", "l3tm3in"), w2.ToString()); + Util.ValidateDataReturnResponseOk(r2); + long w2Id = r2.ObjectResponse["result"]["id"].Value(); + uint OriginalConcurrencyToken = r2.ObjectResponse["result"]["concurrencyToken"].Value(); + + + //PATCH + var newName = Util.Uniquify("PutConcurrencyViolationShouldFail UPDATED VIA PATCH"); + string patchJson = "[{\"value\": \"" + newName + "\",\"path\": \"/name\",\"op\": \"replace\"}]"; + ApiResponse PATCHTestResponse = await Util.PatchAsync("Widget/" + w2Id.ToString() + "/" + (OriginalConcurrencyToken - 1).ToString(), await Util.GetTokenAsync( "manager", "l3tm3in"), patchJson); + Util.ValidateConcurrencyError(PATCHTestResponse); + } + + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Widget/WidgetLists.cs b/test/raven-integration/Widget/WidgetLists.cs new file mode 100644 index 00000000..4dba465d --- /dev/null +++ b/test/raven-integration/Widget/WidgetLists.cs @@ -0,0 +1,74 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Collections.Concurrent; + +namespace raven_integration +{ + + public class WidgetLists + { + + /// + /// + /// + [Fact] + public async void PickListSearchRouteShouldWorkAsExpected() + { + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("Soft PickListSearchRouteShouldWorkAsExpected"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + ApiResponse r1 = await Util.PostAsync("Widget", await Util.GetTokenAsync( "manager", "l3tm3in"), d.ToString()); + Util.ValidateDataReturnResponseOk(r1); + + //Get all + ApiResponse a = await Util.GetAsync("Widget/picklist?Offset=2&Limit=3&q=%25of%25", await Util.GetTokenAsync( "InventoryLimited")); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + ((JArray)a.ObjectResponse["result"]).Count.Should().BeGreaterThan(0); + } + + + /// + /// Paging test + /// + [Fact] + public async void PagingShouldWorkAsExpected() + { + //Get all + ApiResponse a = await Util.GetAsync("Widget/list?Offset=2&Limit=3", await Util.GetTokenAsync( "manager", "l3tm3in")); + Util.ValidateDataReturnResponseOk(a); + Util.ValidateHTTPStatusCode(a, 200); + + //assert aAll contains at least two records + ((JArray)a.ObjectResponse["result"]).Count.Should().Be(3); + + JObject jp = (JObject)a.ObjectResponse["paging"]; + jp["count"].Value().Should().BeGreaterThan(5); + jp["offset"].Value().Should().Be(2); + jp["limit"].Value().Should().Be(3); + jp["first"].Value().Should().EndWith("&pageSize=3"); + jp["previous"].Value().Should().EndWith("&pageSize=3"); + jp["next"].Value().Should().EndWith("&pageSize=3"); + jp["last"].Value().Should().EndWith("&pageSize=3"); + } + + + + + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Widget/WidgetRights.cs b/test/raven-integration/Widget/WidgetRights.cs new file mode 100644 index 00000000..124c7392 --- /dev/null +++ b/test/raven-integration/Widget/WidgetRights.cs @@ -0,0 +1,261 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + // [Collection("APICOLLECTION")] + public class WidgetRights + { + + + /// + /// Test not authorized error return + /// + [Fact] + public async void ServerShouldNotAllowUnauthenticatedAccess() + { + ApiResponse a = await Util.GetAsync("Widget/list"); + Util.ValidateHTTPStatusCode(a, 401); + } + + /// + /// Test insufficient read rights error return + /// + [Fact] + public async void ServerShouldNotAllowReadUnauthorizedAccess() + { + ApiResponse a = await Util.GetAsync("Widget/list", await Util.GetTokenAsync( "OpsAdminFull")); + //2004 unauthorized + Util.ValidateErrorCodeResponse(a, 2004, 401); + } + + + + /// + /// Test insufficient create rights error return + /// + [Fact] + public async void ServerShouldNotAllowCreateUnauthorizedAccess() + { + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldNotAllowCreateUnauthorizedAccess TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //BizAdminLimited user should not be able to create a widget, only read them + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "BizAdminLimited"), d.ToString()); + + //2004 unauthorized + Util.ValidateErrorCodeResponse(a, 2004, 401); + } + + + + /// + /// Test owner rights to modify + /// + [Fact] + public async void ServerShouldAllowOwnerOnlyRightsUserToPatchOwn() + { + + // TECH FULL has owner only rights to widget + + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldAllowOwnerOnlyRightsUserToPatchOwn TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "TechFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long Id = a.ObjectResponse["result"]["id"].Value(); + uint OriginalConcurrencyToken = a.ObjectResponse["result"]["concurrencyToken"].Value(); + + //Now attempt to modify it via patch + var newName = Util.Uniquify("ServerShouldAllowOwnerOnlyRightsUserToPatchOwn - UPDATED TEST WIDGET"); + string patchJson = "[{\"value\": \"" + newName + "\",\"path\": \"/name\",\"op\": \"replace\"}]"; + a = await Util.PatchAsync("Widget/" + Id.ToString() + "/" + OriginalConcurrencyToken.ToString(), await Util.GetTokenAsync( "TechFull"), patchJson); + Util.ValidateHTTPStatusCode(a, 200); + } + + + /// + /// Test owner rights fails to modify other creator object + /// + [Fact] + public async void ServerShouldDisAllowOwnerOnlyRightsUserToPatchNonOwned() + { + // TECH FULL has owner only rights to widget + //INVENTORY FULL has full rights to widget + + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldDisAllowOwnerOnlyRightsUserToPatchNonOwned TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long Id = a.ObjectResponse["result"]["id"].Value(); + uint OriginalConcurrencyToken = a.ObjectResponse["result"]["concurrencyToken"].Value(); + + //Now TechFullAuthToken attempt to modify it via patch + var newName = Util.Uniquify("ServerShouldDisAllowOwnerOnlyRightsUserToPatchNonOwned - UPDATED TEST WIDGET"); + string patchJson = "[{\"value\": \"" + newName + "\",\"path\": \"/name\",\"op\": \"replace\"}]"; + a = await Util.PatchAsync("Widget/" + Id.ToString() + "/" + OriginalConcurrencyToken.ToString(), await Util.GetTokenAsync( "TechFull"), patchJson); + //2004 unauthorized expected + Util.ValidateErrorCodeResponse(a, 2004, 401); + + + } + + + + + + /// + /// Test owner rights to modify + /// + [Fact] + public async void ServerShouldAllowOwnerOnlyRightsUserToPutOwn() + { + + // TECH FULL has owner only rights to widget + + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldAllowOwnerOnlyRightsUserToPutOwn TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "TechFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long Id = a.ObjectResponse["result"]["id"].Value(); + uint OriginalConcurrencyToken = a.ObjectResponse["result"]["concurrencyToken"].Value(); + + //Now attempt to modify it via patch + var newName = Util.Uniquify("ServerShouldAllowOwnerOnlyRightsUserToPutOwn - UPDATED TEST WIDGET"); + d.OwnerId = 1; + d.name = newName; + d.concurrencyToken = OriginalConcurrencyToken; + + a = await Util.PutAsync("Widget/" + Id.ToString(), await Util.GetTokenAsync( "TechFull"), d.ToString()); + Util.ValidateHTTPStatusCode(a, 200); + } + + + /// + /// Test owner rights fails to modify other creator object + /// + [Fact] + public async void ServerShouldDisAllowOwnerOnlyRightsUserToPutNonOwned() + { + // TECH FULL has owner only rights to widget + //INVENTORY FULL has full rights to widget + + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldDisAllowOwnerOnlyRightsUserToPutNonOwned TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long Id = a.ObjectResponse["result"]["id"].Value(); + + //Now TechFullAuthToken attempt to modify it via patch + var newName = Util.Uniquify("ServerShouldDisAllowOwnerOnlyRightsUserToPutNonOwned - UPDATED TEST WIDGET"); + d.name = newName; + a = await Util.PutAsync("Widget/" + Id.ToString(), await Util.GetTokenAsync( "TechFull"), d.ToString()); + //2004 unauthorized expected + Util.ValidateErrorCodeResponse(a, 2004, 401); + + + } + + + + /// + /// Test owner rights to delete + /// + [Fact] + public async void ServerShouldAllowOwnerOnlyRightsUserToDelete() + { + + // TECH FULL has owner only rights to widget + + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldAllowOwnerOnlyRightsUserToDelete TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "TechFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long Id = a.ObjectResponse["result"]["id"].Value(); + + //Now attempt to delete it + a = await Util.DeleteAsync("Widget/" + Id.ToString(), await Util.GetTokenAsync( "TechFull")); + Util.ValidateHTTPStatusCode(a, 204); + } + + + + + /// + /// Test owner rights fails to delete other creator object + /// + [Fact] + public async void ServerShouldDisAllowOwnerOnlyRightsUserToDeleteNonOwned() + { + // TECH FULL has owner only rights to widget + //INVENTORY FULL has full rights to widget + + //CREATE + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldDisAllowOwnerOnlyRightsUserToDeleteNonOwned TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long Id = a.ObjectResponse["result"]["id"].Value(); + + //Now attempt delete + a = await Util.DeleteAsync("Widget/" + Id.ToString(), await Util.GetTokenAsync( "TechFull")); + //2004 unauthorized expected + Util.ValidateErrorCodeResponse(a, 2004, 401); + + + } + + + + + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/Widget/WidgetValidationTests.cs b/test/raven-integration/Widget/WidgetValidationTests.cs new file mode 100644 index 00000000..ef8e9b40 --- /dev/null +++ b/test/raven-integration/Widget/WidgetValidationTests.cs @@ -0,0 +1,260 @@ +using System; +using Xunit; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Generic; +using System.Collections.Concurrent; + +namespace raven_integration +{ + public class WidgetValidationTest + { + + + /// + /// Test business rule should be active on new + /// + [Fact] + public async void BusinessRuleNewShouldBeActiveShouldWork() + { + //CREATE attempt with broken rules + dynamic d = new JObject(); + d.name = Util.Uniquify("ServerShouldDisAllowOwnerOnlyRightsUserToDeleteNonOwned TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = false;//<--- BROKEN RULE new widget must be active = true!! + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "Active", "InvalidValue"); + + } + + + + + /// + /// Test business rule name should be unique + /// + [Fact] + public async void BusinessRuleNameMustBeUnique() + { + //CREATE attempt with broken rules + dynamic d = new JObject(); + d.name = Util.Uniquify("BusinessRuleNameMustBeUnique TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + + //Now try to create again with same name + a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "Name", "NotUnique"); + + } + + + + /// + /// + /// + [Fact] + public async void BusinessRuleNameRequired() + { + + dynamic d = new JObject(); + d.name = ""; + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "Name", "RequiredPropertyEmpty"); + + } + + /// + /// + /// + [Fact] + public async void BusinessRuleNameLengthExceeded() + { + + dynamic d = new JObject(); + d.name = new string('A', 256); ; + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "Name", "LengthExceeded", "255 max"); + + } + + /// + /// + /// + [Fact] + public async void BusinessRuleOwnerIdMustExistOnUpdate() + { + //CREATE attempt with broken rules + dynamic d = new JObject(); + d.name = Util.Uniquify("BusinessRuleOwnerIdMustExistOnUpdate TEST WIDGET"); + d.created = DateTime.Now.ToString(); + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + Util.ValidateDataReturnResponseOk(a); + long Id = a.ObjectResponse["result"]["id"].Value(); + + //Now put a change with no ownerId set + d.active = false; + a = await Util.PutAsync("Widget/" + Id.ToString(), await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "OwnerId", "RequiredPropertyEmpty"); + + } + + + + /// + /// + /// + [Fact] + public async void BusinessRuleStartDateWithoutEndDateShouldError() + { + + dynamic d = new JObject(); + d.name = Util.Uniquify("BusinessRuleStartDateWithoutEndDateShouldError TEST"); + d.created = DateTime.Now.ToString(); + d.startDate = d.created; + //NO END DATE ERRROR + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "EndDate", "RequiredPropertyEmpty"); + + } + + + + /// + /// + /// + [Fact] + public async void BusinessRuleEndDateWithoutStartDateShouldError() + { + + dynamic d = new JObject(); + d.name = Util.Uniquify("BusinessRuleEndDateWithoutStartDateShouldError TEST"); + d.created = DateTime.Now.ToString(); + d.endDate = d.created; + //NO START DATE ERRROR + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "StartDate", "RequiredPropertyEmpty"); + + } + + + /// + /// + /// + [Fact] + public async void BusinessRuleEndDateBeforeStartDateShouldError() + { + + dynamic d = new JObject(); + d.name = Util.Uniquify("BusinessRuleEndDateBeforeStartDateShouldError TEST"); + d.created = DateTime.Now.ToString(); + d.startDate = DateTime.Now.ToString(); + d.endDate = DateTime.Now.AddHours(-1).ToString(); + //NO START DATE ERRROR + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 0; + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "StartDate", "StartDateMustComeBeforeEndDate"); + + } + + + + /// + /// + /// + [Fact] + public async void BusinessRuleEnumInvalidShouldError() + { + + dynamic d = new JObject(); + d.name = Util.Uniquify("BusinessRuleEnumInvalidShouldError TEST"); + d.created = DateTime.Now.ToString(); + + //NO END DATE ERRROR + d.dollarAmount = 1.11m; + d.active = true; + d.roles = 99999;//<---BAD ROLE VALUE + + //create via inventory full test user + ApiResponse a = await Util.PostAsync("Widget", await Util.GetTokenAsync( "InventoryFull"), d.ToString()); + + + //2002 in-valid expected + Util.ValidateErrorCodeResponse(a, 2200, 400); + Util.ShouldContainValidationError(a, "Roles", "InvalidValue"); + + } + + //================================================== + + }//eoc +}//eons diff --git a/test/raven-integration/raven-integration.csproj b/test/raven-integration/raven-integration.csproj new file mode 100644 index 00000000..b9922dc4 --- /dev/null +++ b/test/raven-integration/raven-integration.csproj @@ -0,0 +1,17 @@ + + + + netcoreapp2.1 + false + true + + + + + + + + + + + diff --git a/test/raven-integration/testdata/ayanova.data.dump.xxx.zip b/test/raven-integration/testdata/ayanova.data.dump.xxx.zip new file mode 100644 index 00000000..913608d9 Binary files /dev/null and b/test/raven-integration/testdata/ayanova.data.dump.xxx.zip differ diff --git a/test/raven-integration/testdata/test.png b/test/raven-integration/testdata/test.png new file mode 100644 index 00000000..c1cf6c3f Binary files /dev/null and b/test/raven-integration/testdata/test.png differ diff --git a/test/raven-integration/testdata/test.zip b/test/raven-integration/testdata/test.zip new file mode 100644 index 00000000..a1a3fc59 Binary files /dev/null and b/test/raven-integration/testdata/test.zip differ diff --git a/test/raven-integration/util.cs b/test/raven-integration/util.cs new file mode 100644 index 00000000..f793ccc5 --- /dev/null +++ b/test/raven-integration/util.cs @@ -0,0 +1,373 @@ +using System; +using System.Threading.Tasks; +using System.Net.Http; +using System.Net.Http.Headers; +using Newtonsoft.Json.Linq; +using FluentAssertions; +using System.Collections.Concurrent; + +namespace raven_integration +{ + public static class Util + { + private static HttpClient client { get; } = new HttpClient(); + + private static string API_BASE_URL = "http://localhost:7575/api/v8.0/"; + //private static string API_BASE_URL = "https://test.helloayanova.com/api/v8.0/"; + + public static string TEST_DATA_FOLDER = @"..\..\..\testdata\"; + + public static ConcurrentDictionary authDict = new ConcurrentDictionary();//10,32 + + public static string Uniquify(string s) + { + + return s + ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds(); + } + + public async static Task GetTokenAsync(string login, string password = null) + { + // Console.WriteLine($"GetTokenAsync:{login}"); + //System.Diagnostics.Trace.WriteLine($"GetTokenAsync:{login}"); + + if (password == null) + password = login; + + if (!authDict.ContainsKey(login)) + { + dynamic creds = new JObject(); + creds.login = login; + creds.password = password; + + ApiResponse a = await Util.PostAsync("Auth", null, creds.ToString()); + //Put this in when having concurrency issue during auth and old style dl token creation during login + //ValidateDataReturnResponseOk(a); + + authDict[login] = a.ObjectResponse["result"]["token"].Value(); + } + return authDict[login]; + } + + + + static bool bInitialized = false; + private static void init() + { + if (bInitialized) return; + if (!System.IO.Directory.Exists(TEST_DATA_FOLDER)) + throw new ArgumentOutOfRangeException($"Test data folder {TEST_DATA_FOLDER} not found, current folder is {System.AppDomain.CurrentDomain.BaseDirectory}"); + + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + bInitialized = true; + } + + public static string CleanApiRoute(string route) + { + route = route.TrimStart('/'); + return API_BASE_URL + route; + } + + public async static Task GetAsync(string route, string authToken = null, string bodyJsonData = null) + { + init(); + + var requestMessage = new HttpRequestMessage(HttpMethod.Get, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + if (!string.IsNullOrWhiteSpace(bodyJsonData)) + requestMessage.Content = new StringContent(bodyJsonData, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await client.SendAsync(requestMessage); + var responseAsString = await response.Content.ReadAsStringAsync(); + + return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; + } + + + public async static Task GetTextResultAsync(string route, string authToken = null, string bodyJsonData = null) + { + init(); + + var requestMessage = new HttpRequestMessage(HttpMethod.Get, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + if (!string.IsNullOrWhiteSpace(bodyJsonData)) + requestMessage.Content = new StringContent(bodyJsonData, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await client.SendAsync(requestMessage); + var responseAsString = await response.Content.ReadAsStringAsync(); + + return new ApiTextResponse() { HttpResponse = response, TextResponse = responseAsString }; + } + + public static async Task DownloadFileAsync(string route, string authToken = null) + { + + init(); + var requestMessage = new HttpRequestMessage(HttpMethod.Get, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + HttpResponseMessage response = await client.SendAsync(requestMessage); + return response; + // if (response.IsSuccessStatusCode) + // { + // return await response.Content.ReadAsByteArrayAsync(); + // } + + // return null; + } + + + public async static Task PostAsync(string route, string authToken = null, string postJson = null) + { + init(); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + if (!string.IsNullOrWhiteSpace(postJson)) + requestMessage.Content = new StringContent(postJson, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await client.SendAsync(requestMessage); + var responseAsString = await response.Content.ReadAsStringAsync(); + + return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; + } + + public async static Task PostFormDataAsync(string route, MultipartFormDataContent formContent, string authToken = null) + { + init(); + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + requestMessage.Content = formContent; + + HttpResponseMessage response = await client.SendAsync(requestMessage); + var responseAsString = await response.Content.ReadAsStringAsync(); + + return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; + } + + + public async static Task PutAsync(string route, string authToken = null, string putJson = null) + { + init(); + + var requestMessage = new HttpRequestMessage(HttpMethod.Put, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + if (!string.IsNullOrWhiteSpace(putJson)) + requestMessage.Content = new StringContent(putJson, System.Text.Encoding.UTF8, "application/json"); + + HttpResponseMessage response = await client.SendAsync(requestMessage); + var responseAsString = await response.Content.ReadAsStringAsync(); + + return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; + } + + + public async static Task PatchAsync(string route, string authToken = null, string patchJson = null) + { + init(); + + var method = new HttpMethod("PATCH"); + + var requestMessage = new HttpRequestMessage(method, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + if (!string.IsNullOrWhiteSpace(patchJson)) + requestMessage.Content = new StringContent(patchJson, System.Text.Encoding.UTF8, "application/json-patch+json"); + + HttpResponseMessage response = await client.SendAsync(requestMessage); + var responseAsString = await response.Content.ReadAsStringAsync(); + + return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; + } + + + public async static Task DeleteAsync(string route, string authToken = null) + { + init(); + + var requestMessage = new HttpRequestMessage(HttpMethod.Delete, CleanApiRoute(route)); + if (!string.IsNullOrWhiteSpace(authToken)) + requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); + + HttpResponseMessage response = await client.SendAsync(requestMessage); + var responseAsString = await response.Content.ReadAsStringAsync(); + + return new ApiResponse() { HttpResponse = response, ObjectResponse = Parse(responseAsString) }; + } + + + + /// + /// + /// + /// + /// + private static JObject Parse(string jsonString) + { + if (string.IsNullOrWhiteSpace(jsonString)) + { + return null; + } + return JObject.Parse(jsonString); + } + + //https://www.newtonsoft.com/json/help/html/FromObject.htm + private static string ObjectToJsonString(object o) + { + JObject j = (JObject)JToken.FromObject(o); + return j.ToString(); + } + + public static void ValidateDataReturnResponseOk(ApiResponse a) + { + a.ObjectResponse["error"].Should().BeNull("There should not be an error on an api call"); + a.ObjectResponse["result"].Should().NotBeNull("A result should be returned"); + } + + public static void ValidateNoErrorInResponse(ApiResponse a) + { + a.ObjectResponse["error"].Should().BeNull("There should not be an error on an api call"); + } + + public static void ValidateHTTPStatusCode(ApiResponse a, int DesiredStatusCode) + { + ((int)a.HttpResponse.StatusCode).Should().Be(DesiredStatusCode); + } + + public static void ValidateHTTPStatusCode(ApiTextResponse t, int DesiredStatusCode) + { + ((int)t.HttpResponse.StatusCode).Should().Be(DesiredStatusCode); + } + + /// + /// validate a not found response + /// + /// + public static void ValidateResponseNotFound(ApiResponse a) + { + ((int)a.HttpResponse.StatusCode).Should().Be(404); + a.ObjectResponse["error"].Should().NotBeNull("There should be an error on the api call"); + a.ObjectResponse["error"]["code"].Value().Should().Be(2010); + } + + + /// + /// validate a concurrency error + /// + /// + public static void ValidateConcurrencyError(ApiResponse a) + { + ((int)a.HttpResponse.StatusCode).Should().Be(409); + a.ObjectResponse["error"].Should().NotBeNull("There should be an error on the api call"); + a.ObjectResponse["error"]["code"].Value().Should().Be(2005); + } + + + /// + /// validate that the call violates referential integrity + /// + /// + public static void ValidateViolatesReferentialIntegrityError(ApiResponse a) + { + ((int)a.HttpResponse.StatusCode).Should().Be(400); + a.ObjectResponse["error"].Should().NotBeNull("There should be an error on the api call"); + a.ObjectResponse["error"]["code"].Value().Should().Be(2200); + } + + + + /// + /// validate a bad ModelState response + /// + /// + public static void ValidateBadModelStateResponse(ApiResponse a, string CheckFirstTargetExists = null) + { + ((int)a.HttpResponse.StatusCode).Should().Be(400); + a.ObjectResponse["error"].Should().NotBeNull("There should be an error on the api call"); + a.ObjectResponse["error"]["code"].Value().Should().Be(2200); + a.ObjectResponse["error"]["details"].Should().NotBeNull("There should be error details on the api call"); + if (!string.IsNullOrWhiteSpace(CheckFirstTargetExists)) + { + a.ObjectResponse["error"]["details"][0]["target"].Value().Should().Be(CheckFirstTargetExists); + } + } + + + + + // public enum ValidationErrorType + // { + // RequiredPropertyEmpty = 1, + // LengthExceeded = 2, + // NotUnique = 3, + // StartDateMustComeBeforeEndDate = 4, + // InvalidValue = 5 + + // } + + /// + /// assert contains validation target and error code + /// + /// + /// + /// + public static void ShouldContainValidationError(ApiResponse a, string target, string error, string message = null) + { + a.ObjectResponse["error"]["details"].Should().NotBeNull("There should be Details on the api call"); + if (message != null) + { + a.ObjectResponse["error"]["details"].Should().Contain( + m => m["target"].Value() == target && + m["error"].Value() == error && + m["message"].Value() == message); + } + else + { + a.ObjectResponse["error"]["details"].Should().Contain(m => m["target"].Value() == target && m["error"].Value() == error); + } + } + + + /// + /// validate server exception response + /// + /// + public static void ValidateServerExceptionResponse(ApiResponse a) + { + ((int)a.HttpResponse.StatusCode).Should().Be(500); + a.ObjectResponse["error"].Should().NotBeNull("There should be an error on the api call"); + a.ObjectResponse["error"]["code"].Value().Should().Be(2002); + } + + + /// + /// Validate an expected api error code and http code response + /// + /// + /// + /// + public static void ValidateErrorCodeResponse(ApiResponse a, int apiErrorCode, int httpStatusCode) + { + ((int)a.HttpResponse.StatusCode).Should().Be(httpStatusCode); + a.ObjectResponse["error"].Should().NotBeNull("There should be an error on the api call"); + a.ObjectResponse["error"]["code"].Value().Should().Be(apiErrorCode); + } + + + + + }//eoc +}//eons