Merge remote-tracking branch 'origin/master'

This commit is contained in:
Gan, Jimmy
2025-06-25 23:48:22 +08:00
119 changed files with 21011 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# .env.development
#
# Environment variables for the Development environment.
# --- Database Configuration ---
DB_USER=postgres
DB_PASSWORD=postgres
DB_HOST=db_development # Connects to the development database service name
DB_PORT=5432
DB_NAME=mold_cost_development
# --- Application Configuration ---
SECRET_KEY='a_different_secret_key_for_development'
FLASK_DEBUG=1
HOST_ENV=local # Set AWS for AWS deployments
+135
View File
@@ -123,3 +123,138 @@ powerapps/
fix_ssl_*.sh
quick_ssl_*.sh
download_static_files.sh
# CDK asset staging directory
.cdk.staging
cdk.out
# Miscellaneous
*.js
!jest.config.js
*.d.ts
node_modules
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Flask
instance/
.webassets-cache
# Database
*.db
*.sqlite
*.sqlite3
*.db-journal
*.sqlite-journal
*.sqlite3-journal
app.db
instance/app.db
*.bak
*.backup
backups/
db_data/
# Virtual Environment
venv/
.venv/
ENV/
env/
venv.bak/
.venv.bak/
venv/Scripts/
venv/bin/
venv/include/
venv/lib/
venv/Lib/
venv/share/
venv/pyvenv.cfg
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
.env.production
.venv
venv/
ENV/
# Logs
*.log
logs/
# System
.DS_Store
Thumbs.db
# Docker
mold-cost-calculator.tar mold_cost_tool.tar
# New test file and database sync log file
*.sql
*.zip
DEV_BRANCH_TEST.md
# Logs
!logs/deploy.log
!logs/db_sync.log
logs/*
!logs/.gitkeep
db_sync.log
# Coverage and Testing
.coverage
.coverage.*
htmlcov/
.pytest_cache/
.tox/
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Temporary and Proposal Files
*.proposal
*_proposal.*
files-to-delete.txt
*.tmp
*.temp
# Archive Files
*.tar
*.tar.gz
*.zip
*.rar
*.7z
# PowerApps (separate project)
powerapps/
# SSL Scripts (temporary fix scripts)
fix_ssl_*.sh
quick_ssl_*.sh
download_static_files.sh
Vendored
+98
View File
@@ -0,0 +1,98 @@
@Library('GlobalJenkinsLibraryNew') _
def cdkDeployAccount = [
'poc': '615817712084',
'dev': '030215556060',
'uat': '534745294648',
'prd': '022438277857',
]
def credentialsIDs = [
'poc' : 'ProductDesign Jenkins Deployment POC',
'dev' : 'digital-creation Development Jenkins Deployment',
'uat' : 'digital-creation UAT Jenkins Deployment',
'prd' : 'digital-creation PRD Jenkins Deployment',
]
def mccDatabasePassword = [
'poc': 'MCC_POC_DATABASE_PASSWORD',
'dev': 'MCC_DEV_DATABASE_PASSWORD',
'uat': 'MCC_UAT_DATABASE_PASSWORD',
'prd': 'MCC_PRD_DATABASE_PASSWORD',
]
def mccDatabase = [
'poc': 'devmccmccdb-devmccmccdbwriterfe3d6e74-nzeetdanw6sx.c8uwkt51amck.eu-west-1.rds.amazonaws.com/mccdb',
'dev': 'dev-mld-mccdb.cluster-c8uwkt51amck.eu-west-1.rds.amazonaws.com/mccdb',
'uat': 'devmccmccdb-devmccmccdbwriterfe3d6e74-nzeetdanw6sx.c8uwkt51amck.eu-west-1.rds.amazonaws.com/mccdb',
'prd': 'devmccmccdb-devmccmccdbwriterfe3d6e74-nzeetdanw6sx.c8uwkt51amck.eu-west-1.rds.amazonaws.com/mccdb',
]
def stageChoices = ['poc', 'dev', 'uat']
if (env.BRANCH_NAME == 'main' || env.BRANCH_NAME == 'master') {
stageChoices = ['poc', 'dev', 'uat', 'prd']
}
def now = new Date()
pipeline {
environment {
DEPLOY_ENV = "${params.STAGE}"
HOME = "${WORKSPACE}"
CDK_HOME = "${WORKSPACE}"
CDK_DEPLOY_ACCOUNT = "${cdkDeployAccount[DEPLOY_ENV]}"
CDK_DEPLOY_REGION = 'eu-west-1'
CDK_QUALIFIER = "${DEPLOY_ENV}-mld"
STACK_NAME = 'MoldCostCalc'
POSTGRES_USER = 'mccAdmin'
POSTGRES_PASSWORD_ENV = "${mccDatabasePassword[DEPLOY_ENV]}"
FLASK_APP = 'run.py'
FLASK_ENV = 'production'
DATABASE_URL = "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${mccDatabase[DEPLOY_ENV]}"
HOST_ENV = 'AWS'
}
agent {
label 'ubuntu_node-20_docker-20'
}
parameters {
choice(name: 'ACTION', choices: ['Analysis', 'Deploy'], description: 'Deployment Type')
choice(name: 'STAGE', choices: stageChoices, description: 'Environment to deploy. Must match with the value in CDK.JSON context')
}
options {
disableConcurrentBuilds()
parallelsAlwaysFailFast()
timestamps()
buildDiscarder(logRotator(numToKeepStr: '3'))
}
stages {
stage('Deploy') {
when {
expression { return params.ACTION == 'Deploy' }
}
steps {
dir('infra') {
withCredentials([
string(credentialsId: "${POSTGRES_PASSWORD_ENV}", variable: 'POSTGRES_PASSWORD'),
aws(credentialsId: credentialsIDs[env.DEPLOY_ENV], accessKeyVariable: 'AWS_ACCESS_KEY_ID', secretKeyVariable: 'AWS_SECRET_ACCESS_KEY')
]) {
withNvm {
sh 'npm install'
sh "npx aws-cdk@2.1019.1 bootstrap aws://${env.CDK_DEPLOY_ACCOUNT}/${env.CDK_DEPLOY_REGION} --context config=${env.DEPLOY_ENV} --toolkit-stack-name ${env.DEPLOY_ENV}${env.STACK_NAME} --change-set-name changeset-${env.DEPLOY_ENV}-${currentBuild.number} --qualifier ${env.CDK_QUALIFIER}"
sh "npx aws-cdk@2.1019.1 deploy --all --context config=${env.DEPLOY_ENV} --toolkit-stack-name ${env.DEPLOY_ENV}${env.STACK_NAME} --change-set-name ${env.STACK_NAME}-changeset-${env.DEPLOY_ENV}-${currentBuild.number} --require-approval never"
}
}
}
}
}
}
post {
always {
cleanWs()
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import {
Stack,
StackProps,
aws_apigateway as apigateway,
aws_lambda,
aws_ec2,
Duration,
} from 'aws-cdk-lib';
import * as aws_lambda_python_alpha from '@aws-cdk/aws-lambda-python-alpha';
import { Construct } from 'constructs';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
import { join } from 'path';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { BuildConfig } from '../utils/build-config';
import { Period } from 'aws-cdk-lib/aws-apigateway';
import { RetentionDays } from 'aws-cdk-lib/aws-logs';
import { VpcEndpoint, VpcEndpointService } from 'aws-cdk-lib/aws-ec2';
import { PolicyStatement, PolicyDocument, Effect, AnyPrincipal } from 'aws-cdk-lib/aws-iam';
export class MccAPIInfraStack extends Stack {
public readonly api: apigateway.RestApi;
constructor(
scope: Construct,
id: string,
buildConfig: BuildConfig,
props: StackProps,
) {
super(scope, id, props);
const stackName = `${buildConfig.Environment}${buildConfig.ApplicationShortName}`;
const vpc = aws_ec2.Vpc.fromLookup(this, `${stackName}Vpc`, {
vpcId: buildConfig.Parameters.VPC,
});
const executeApiVpcEndpointSecurityGroup = new aws_ec2.SecurityGroup(this, `${stackName}ExecuteApiVPCEsg`, {
vpc,
description: 'Security group for API Gateway VPC Endpoint',
allowAllOutbound: true,
});
executeApiVpcEndpointSecurityGroup.addIngressRule(
aws_ec2.Peer.ipv4('10.0.0.0/8'),
aws_ec2.Port.tcp(443),
'Allow inbound HTTPS traffic from 10.0.0.0/8'
);
const executeApiVpcEndpoint = new aws_ec2.InterfaceVpcEndpoint(this, `${stackName}ExecuteApiVPCE`, {
vpc,
service: aws_ec2.InterfaceVpcEndpointAwsService.APIGATEWAY,
privateDnsEnabled: true,
subnets: { subnetType: aws_ec2.SubnetType.PRIVATE_WITH_EGRESS },
securityGroups: [executeApiVpcEndpointSecurityGroup]
});
const lambdaFunction = new aws_lambda_python_alpha.PythonFunction(this, `${stackName}MCCApp`, {
runtime: Runtime.PYTHON_3_11,
handler: 'lambda_handler',
timeout: Duration.seconds(30),
entry: join(__dirname, '../../src'),
index: 'run.py',
memorySize: 256,
vpc,
logRetention: RetentionDays.ONE_WEEK,
environment: {
POSTGRES_USER: process.env.POSTGRES_USER!,
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD!,
POSTGRES_DB: 'mccdb',
FLASK_APP: process.env.FLASK_APP!,
FLASK_ENV: process.env.FLASK_ENV!,
DATABASE_URL: process.env.DATABASE_URL!,
HOST_ENV: process.env.HOST_ENV!,
},
});
const lambdaDBUpgradeFunction = new aws_lambda_python_alpha.PythonFunction(this, `${stackName}MCCAppDBUpgrade`, {
runtime: Runtime.PYTHON_3_11,
handler: 'lambda_handler_upgrade_db',
timeout: Duration.minutes(5),
entry: join(__dirname, '../../src'),
index: 'run.py',
memorySize: 1024,
vpc,
logRetention: RetentionDays.ONE_WEEK,
functionName: `${stackName}MCCAppDBUpgrade`,
environment: {
POSTGRES_USER: process.env.POSTGRES_USER!,
POSTGRES_PASSWORD: process.env.POSTGRES_PASSWORD!,
POSTGRES_DB: 'mccdb',
FLASK_APP: process.env.FLASK_APP!,
FLASK_ENV: process.env.FLASK_ENV!,
DATABASE_URL: process.env.DATABASE_URL!,
HOST_ENV: process.env.HOST_ENV!,
},
});
const api = new apigateway.LambdaRestApi(this, `${stackName}MCCAPI`, {
handler: lambdaFunction,
proxy: true,
deployOptions: {
stageName: buildConfig.Environment,
},
restApiName: `${buildConfig.ApplicationName} API`,
description: `API for ${buildConfig.ApplicationName} in ${buildConfig.Environment}`,
endpointConfiguration: {
types: [apigateway.EndpointType.PRIVATE],
vpcEndpoints: [executeApiVpcEndpoint],
},
policy: new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
principals: [new AnyPrincipal()],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*/*/*'],
// Optionally, restrict by source VPC or VPC endpoint
conditions: {
StringEquals: { 'aws:SourceVpce': executeApiVpcEndpoint.vpcEndpointId }
}
}),
new PolicyStatement({
effect: Effect.DENY,
principals: [new AnyPrincipal()],
actions: ['execute-api:Invoke'],
resources: ['execute-api:/*/*/*'],
// Optionally, restrict by source VPC or VPC endpoint
conditions: {
StringNotEquals: { 'aws:SourceVpce': executeApiVpcEndpoint.vpcEndpointId }
}
}),
],
}),
});
this.api = api;
}
}
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { getConfig, BuildConfig } from '../utils/build-config';
import { MccAPIInfraStack } from './apiStack';
import { MccDBInfraStack } from './rdsStack';
const app = new cdk.App();
const buildConfig: BuildConfig = getConfig(app);
const qualifier = process.env.CDK_QUALIFIER;
const stackName = `${buildConfig.Environment}${buildConfig.ApplicationShortName}`;
cdk.Tags.of(app).add('App', buildConfig.ApplicationName);
cdk.Tags.of(app).add('Environment', buildConfig.Environment);
cdk.Tags.of(app).add('AppManagerCFNStackKey', stackName);
cdk.Tags.of(app).add('purpose', `${buildConfig.Environment}-${buildConfig.ApplicationShortName}`);
const env: cdk.Environment = {
account: buildConfig.Account ?? process.env.CDK_DEPLOY_ACCOUNT ?? process.env.CDK_DEFAULT_ACCOUNT,
region: buildConfig.Region ?? process.env.CDK_DEPLOY_REGION ?? process.env.CDK_DEFAULT_REGION,
};
const apiStack = new MccAPIInfraStack(app, `${stackName}MCCAPI`, buildConfig, {
env,
description: `API stack for ${buildConfig.ApplicationName} in ${buildConfig.Environment}`,
synthesizer: new cdk.DefaultStackSynthesizer({
qualifier,
}),
});
const dbStack = new MccDBInfraStack(app, `${stackName}MCCDB`, buildConfig, {
env,
description: `Database stack for ${buildConfig.ApplicationName} in ${buildConfig.Environment}`,
synthesizer: new cdk.DefaultStackSynthesizer({
qualifier,
}),
});
+79
View File
@@ -0,0 +1,79 @@
import {
Stack,
aws_ec2 as ec2,
aws_rds as rds,
StackProps,
RemovalPolicy,
} from 'aws-cdk-lib';
import { Port } from 'aws-cdk-lib/aws-ec2';
import { Construct } from 'constructs';
import { BuildConfig } from '../utils/build-config';
import { ClusterInstance } from 'aws-cdk-lib/aws-rds';
export class MccDBInfraStack extends Stack {
public readonly mccDB: rds.DatabaseCluster;
public readonly mccDBSecurityGroup: ec2.SecurityGroup;
credentials = rds.Credentials.fromGeneratedSecret('mccAdmin');
constructor(scope: Construct, id: string, buildConfig: BuildConfig, props: StackProps) {
super(scope, id, props);
const vpc = ec2.Vpc.fromLookup(
this,
`${buildConfig.Environment}-${buildConfig.ApplicationShortName}MccVPC`,
{
vpcId: buildConfig.Parameters.VPC,
}
);
const dbSecurityGroup = new ec2.SecurityGroup(
this,
`${buildConfig.Environment}${buildConfig.ApplicationShortName}MccDBSecurityGroup`,
{
vpc: vpc,
securityGroupName: `${buildConfig.Environment}-${buildConfig.ApplicationShortName}DBSecurityGroup`,
}
);
const mccDB = new rds.DatabaseCluster(this, `${buildConfig.Environment}${buildConfig.ApplicationShortName}MccDB`, {
engine: rds.DatabaseClusterEngine.auroraPostgres({
version: rds.AuroraPostgresEngineVersion.VER_17_4,
}) ,
vpc,
writer: ClusterInstance.serverlessV2('writer') ,
credentials: this.credentials,
defaultDatabaseName: 'mccdb',
securityGroups: [dbSecurityGroup],
serverlessV2MinCapacity: 0,
serverlessV2MaxCapacity: 64,
enableDataApi: true,
clusterIdentifier: `${buildConfig.Environment}-${buildConfig.ApplicationShortName}-mccdb`,
removalPolicy: RemovalPolicy.RETAIN
});
const lambdaSecurityGroup = new ec2.SecurityGroup(
this,
`${buildConfig.Environment}${buildConfig.ApplicationShortName}MccDBLambdaSecurityGroup`,
{
vpc: vpc,
securityGroupName: `${buildConfig.Environment}-${buildConfig.ApplicationShortName}DBLambdaSecurityGroup`,
}
);
dbSecurityGroup.addIngressRule(
lambdaSecurityGroup,
Port.tcp(mccDB.clusterEndpoint.port),
'Allow connection from Lambda Function to DB'
);
dbSecurityGroup.addIngressRule(
ec2.Peer.ipv4('10.0.0.0/8'),
Port.tcp(mccDB.clusterEndpoint.port),
'Allow connection from internal network'
);
this.mccDB = mccDB;
this.mccDBSecurityGroup = lambdaSecurityGroup;
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"vpc-provider:account=030215556060:filter.vpc-id=vpc-02e23456a3e6064f1:region=eu-west-1:returnAsymmetricSubnets=true": {
"vpcId": "vpc-02e23456a3e6064f1",
"vpcCidrBlock": "10.157.7.0/24",
"ownerAccountId": "030215556060",
"availabilityZones": [],
"subnetGroups": [
{
"name": "Private",
"type": "Private",
"subnets": [
{
"subnetId": "subnet-014953bcd8175abe5",
"cidr": "10.157.7.0/25",
"availabilityZone": "eu-west-1a",
"routeTableId": "rtb-0e60c84be1611577e"
},
{
"subnetId": "subnet-0fb151177f9b7d430",
"cidr": "10.157.7.128/26",
"availabilityZone": "eu-west-1b",
"routeTableId": "rtb-09dc6ac1e26eb92ba"
},
{
"subnetId": "subnet-0696d516a7e905674",
"cidr": "10.157.7.192/26",
"availabilityZone": "eu-west-1c",
"routeTableId": "rtb-04cc2504f623b4d2e"
}
]
}
]
}
}
+110
View File
@@ -0,0 +1,110 @@
{
"app": "npx ts-node --prefer-ts-exts bin/infra.ts",
"watch": {
"include": [
"**"
],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": [
"aws",
"aws-cn"
],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-route53-patters:useCertificate": true,
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
"@aws-cdk/aws-redshift:columnId": true,
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
"@aws-cdk/aws-kms:aliasNameRef": true,
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
"@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
"@aws-cdk/aws-efs:denyAnonymousAccess": true,
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
"@aws-cdk/aws-eks:nodegroupNameAttribute": true,
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true,
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true,
"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true,
"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true,
"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true,
"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true,
"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true,
"@aws-cdk/core:enableAdditionalMetadataCollection": true,
"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false,
"@aws-cdk/aws-s3:setUniqueReplicationRoleName": true,
"@aws-cdk/aws-events:requireEventBusPolicySid": true,
"@aws-cdk/core:aspectPrioritiesMutating": true,
"@aws-cdk/aws-dynamodb:retainTableReplica": true,
"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true,
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": true,
"dev": {
"Account": "030215556060",
"Region": "eu-west-1",
"ApplicationName": "Mold Cost Calculator",
"ApplicationShortName": "mld",
"Environment": "dev",
"Version": "1.0.0",
"Parameters": {
"VPC": "vpc-02e23456a3e6064f1",
"TenantID": "3bfeb222-e42c-4535-aace-ea6f7751369b",
"ClientID": "bc56643f-7002-4df9-ada5-1d79f55da6e3"
}
}
}
}
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
testEnvironment: 'node',
roots: ['<rootDir>/test'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest'
}
};
+4470
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "infra",
"version": "0.1.0",
"bin": {
"infra": "bin/infra.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "cdk"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "22.7.9",
"aws-cdk": "2.1019.1",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "~5.6.3"
},
"dependencies": {
"@aws-cdk/aws-lambda-python-alpha": "^2.202.0-alpha.0",
"aws-cdk-lib": "2.202.0",
"constructs": "^10.0.0"
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"es2022"
],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"typeRoots": [
"./node_modules/@types"
]
},
"exclude": [
"node_modules",
"cdk.out"
]
}
+64
View File
@@ -0,0 +1,64 @@
import * as cdk from 'aws-cdk-lib';
interface BuildParameters {
readonly VPC: string;
readonly TenantID: string;
readonly ClientID: string;
}
interface BuildConfig {
readonly Account: string;
readonly Region: string;
readonly ApplicationName: string;
readonly ApplicationShortName: string;
readonly Environment: string;
readonly Version: string;
readonly Parameters: BuildParameters;
}
const getValidStringValueOfKey = (buildConfig: any, key: string): string => {
if (!buildConfig[key] || buildConfig[key].trim().length === 0) {
throw new Error(key + ' does not exist or is empty in build config');
}
return buildConfig[key];
};
const getValidNumberValueOfKey = (buildConfig: any, key: string): number => {
if (!buildConfig[key]) {
throw new Error(key + ' does not exist or is empty in build config');
}
return buildConfig[key];
};
const getConfig = (app: cdk.App): BuildConfig => {
const env = app.node.tryGetContext('config');
if (!env) {
throw new Error('Context variable missing on CDK command. Pass in as "-c config=env"');
}
const buildConfig: any = app.node.tryGetContext(env);
if (!buildConfig) {
throw new Error('Wrong Context variable passed on CDK command. Pass in as "-c config=env"');
}
const validBuildConfig: BuildConfig = {
Account: getValidStringValueOfKey(buildConfig, 'Account'),
Region: getValidStringValueOfKey(buildConfig, 'Region'),
Version: getValidStringValueOfKey(buildConfig, 'Version'),
ApplicationName: getValidStringValueOfKey(buildConfig, 'ApplicationName'),
ApplicationShortName: getValidStringValueOfKey(buildConfig, 'ApplicationShortName'),
Environment: getValidStringValueOfKey(buildConfig, 'Environment'),
Parameters: {
VPC: getValidStringValueOfKey(buildConfig['Parameters'], 'VPC'),
TenantID: getValidStringValueOfKey(buildConfig['Parameters'], 'TenantID'),
ClientID: getValidStringValueOfKey(buildConfig['Parameters'], 'ClientID')
},
};
return validBuildConfig;
};
export { BuildConfig, BuildParameters, getConfig };
+46
View File
@@ -0,0 +1,46 @@
FROM python:3.11-slim AS builder
# Set working directory
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Final stage
FROM python:3.11-slim
# Set working directory
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
# Copy site-packages and binaries from builder
COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/
COPY --from=builder /usr/local/bin/ /usr/local/bin/
# Create necessary directories
RUN mkdir -p /app/logs /app/instance && \
chmod -R 777 /app/logs /app/instance
# Copy application files
COPY . .
# Set environment variables
ENV FLASK_APP=run.py
ENV FLASK_ENV=production
# Expose port
EXPOSE 5002
# Run gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:5002", "--workers", "4", "app:create_app()"]
+205
View File
@@ -0,0 +1,205 @@
# Mold Cost Calculator
A Flask-based web application for calculating mold costs and managing quotations.
## Features
- **Cost Calculation**
- Mold type-based calculations
- Complexity factors (sidewall, slider, cavity)
- Texture options (standard, custom, laser)
- Tax rate support for different countries
- **User Management**
- Secure authentication system
- Role-based access control
- Admin dashboard for user management
- Email-based registration with allowlist
- **Quotation System**
- Create and manage quotations
- Export functionality
- Historical record keeping
- Admin review and management
## Tech Stack
- **Backend**: Flask, SQLAlchemy
- **Frontend**: HTML, CSS, JavaScript
- **Database**: PostgreSQL (production/development), SQLite in-memory (testing)
- **Server**: Gunicorn
- **Proxy**: Nginx
- **Authentication**: Flask-Login
- **Forms**: Flask-WTF
- **Database Migrations**: Flask-Migrate
- **Containerization**: Podman/Docker
## Project Structure
```
mold_cost_online_tool/
├── app/ # Application package
│ ├── forms/ # Form definitions
│ ├── models/ # Database models
│ ├── static/ # Static files (CSS, JS)
│ └── templates/ # HTML templates
├── instance/ # Instance-specific files
├── logs/ # Application logs
├── migrations/ # Database migrations
├── tests/ # Test files
├── deploy/ # Deployment configuration
├── db_data/ # Database data (development)
├── app.py # Main application file
├── config.py # Configuration
├── run.py # Application entry point
├── requirements.txt # Python dependencies
├── gunicorn_config.py # Gunicorn configuration
├── podman-compose.yml # Production container setup
└── podman-compose.development.yml # Development container setup
```
## Setup and Installation
### Option 1: Using Containers (Recommended)
1. **Clone the repository**
```bash
git clone <repository-url>
cd mold_cost_online_tool
```
2. **Set up environment variables**
Create `.env.development` file for development:
```bash
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_db_password
POSTGRES_DB=mold_cost
DATABASE_HOST=db_development
SECRET_KEY=your-secret-key-here
MAIL_SERVER=smtp.example.com
MAIL_USERNAME=your-email@example.com
MAIL_PASSWORD=your-mail-password
MAIL_DEFAULT_SENDER=your-email@example.com
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your-admin-password
```
3. **Start the development environment**
```bash
podman-compose -f podman-compose.development.yml up --build
```
4. **Initialize the database**
```bash
flask db upgrade
```
### Option 2: Local Development
1. **Clone the repository**
```bash
git clone <repository-url>
cd mold_cost_online_tool
```
2. **Create and activate virtual environment**
```bash
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
```
3. **Install dependencies**
```bash
pip install -r requirements.txt
```
4. **Set up PostgreSQL database**
- Install PostgreSQL
- Create database and user
- Set environment variables for database connection
5. **Set up environment variables**
```bash
export FLASK_APP=run.py
export FLASK_ENV=development
export SECRET_KEY=your-secret-key-here
export DATABASE_URL=postgresql://your_user:your_password@localhost/your_dbname
```
6. **Initialize the database**
```bash
flask db upgrade
```
## Development
1. **Run development server**
```bash
python run.py
```
2. **Run tests**
```bash
python -m pytest tests/
```
## Deployment
1. **Production server setup**
- Configure Nginx as reverse proxy
- Set up Gunicorn with provided configuration
- Configure SSL certificates
- Set up PostgreSQL database
2. **Environment variables for production**
```bash
FLASK_APP=run.py
FLASK_ENV=production
SECRET_KEY=your-secure-secret-key-here
DATABASE_URL=postgresql://your_user:your_password@your_host/your_dbname
```
3. **Start the application**
```bash
gunicorn -c gunicorn_config.py run:app
```
## Database Management
- **Backups**: Automated daily backups using `pg_dump`
- **Migrations**: Use Flask-Migrate for database schema changes
- **Development**: Uses PostgreSQL container with persistent data
- **Testing**: Uses SQLite in-memory database for fast, isolated tests
## Security Features
- CSRF protection
- Secure password hashing
- Session management
- Input validation
- Security headers
- Rate limiting
- SQL injection prevention
## Maintenance
- Regular database backups
- Log rotation
- Security updates
- Performance monitoring
## Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
## License
PCT & TOOLING app, all rights reserved.
## Support
For support and inquiries, please contact: jimmy.gan@adidas.com
+180
View File
@@ -0,0 +1,180 @@
# 🏭 Mold Cost Calculator - User Guide
Welcome to the Mold Cost Calculator! This guide will help you quickly and easily estimate the cost of manufacturing molds for your footwear projects.
---
## 📋 Quick Start Guide
### 🚀 First Time Here?
1. **Register** → Create your account
2. **Login** → Access the calculator
3. **Fill Form** → Enter your project details
4. **Calculate** → Get instant cost estimates
### 🔄 Returning User?
Simply login and start calculating!
---
## 🔐 Getting Started
### Creating Your Account
**Step 1:** Visit the Mold Cost Calculator website
**Step 2:** Click the "Register" button
**Step 3:** Fill in your details:
- **Username** (choose something memorable)
- **Email** (use your work email)
- **Company** (your organization name)
- **Password** (make it secure!)
**Step 4:** Click "Register" and you're ready to go!
### Logging In
- Enter your **email** and **password**
- Click "Login"
- You'll be taken to your dashboard
> 💡 **Forgot your password?** Contact your system administrator for help.
---
## 🧮 Using the Calculator
The calculator is organized into 4 main sections. Fill them out in order for the best experience!
### 📊 Section 1: Factory Information
Tell us about the factories involved in your project:
#### Mold Shop (Where the mold is made)
- **Name**: Enter the mold manufacturing facility name
- **Country**: Choose from China, Vietnam, or Indonesia
#### T1 Shoe Factory (Primary shoe manufacturer)
- **Name**: Enter the main shoe factory name
- **Country**: Select the factory location
#### T2 Component Factory (Component supplier)
- **Name**: Enter the component factory name
- **Country**: Select the component factory location
### 📋 Section 2: Project Details
Basic information about your mold project:
- **Model Name**: The shoe model name or code
- **Tooling ID**: A 5-character identification code (must be exactly 5 digits, e.g., "12345")
- **Season**: Project season (e.g., "SS25", "FW24")
- **Stage**: Current project phase:
- **CR0, CR1, CR2** = Concept Review stages
- **SMS** = Sample Manufacturing Stage
- **Commercialization** = Commercial production
- **Production** = Full production
- **Type**: Automatically set based on your stage selection
### ⚙️ Section 3: Mold Specifications
This is where you define your mold's technical details:
#### Mold Type Selection
**Main Type** → **Sub Type****Specific Type** (if applicable)
Choose your mold type from these options:
| Main Type | Sub Types Available |
|-----------|-------------------|
| **Rubber** | Flat Outsole, Cupsole, Rubber Sole |
| **CMEVA** | 2 Plates Mold, 3 Plates Mold, 2 Plates with in-mold channel, Breathable + in-mold channel |
| **IMEVA** | 2 Plates Mold, 3 Plates Mold |
| **Sandal** | Sandal with Upper Bandage, 2 Plate without Upper Bandage |
| **Co-shot mold** | Co-shot mold, Foaming mold |
| **PU** | 2 Plates Mold, 3 Plates Mold |
#### Technical Details
- **Number of Sliders**: 0-4 sliders
- **Number of Cavities**: 0.5 to 3 pairs
- **Digital Texture**:
- adidas digital texture
- customized texture
- laser texture
- **High Sidewall >60mm**: Yes/No
**Specific Types Available:**
- **Flat Outsole**: Flat, Flat with Top/Heel Tip
- **Cupsole**: Top PL, Visible PL
- **Rubber Sole**: Top PL, Short PL (Heel/Forefoot), Visible PL, Wave PL
### 📅 Section 4: Dates & Approvals
Final details for your quotation:
- **Issue Date**: When this quote is issued
- **Mold Shop Approver**: Name of mold shop representative
- **T1 Tooling Approver**: Name of T1 tooling representative
- **LO Tooling Approver**: Name of LO tooling representative
### 🎯 Calculate Your Cost
Click the big blue **"Calculate"** button to get your instant cost estimate!
---
## 💰 Understanding Your Results
After calculation, you'll see a detailed breakdown:
### Cost Summary
- **Net Base Price**: Base mold cost (before taxes)
- **Tax Rate**: Applicable tax percentage
- **Total Cost**: Final cost including taxes
### Project Summary
A complete overview of all your entered information for easy reference.
### 🔧 Technical Issues
**Q: The dropdowns are greyed out!**
A: Make sure you've selected the previous field first. The dropdowns work in sequence.
**Q: The page isn't loading properly**
A: Try refreshing your browser or clearing your cache (Ctrl+F5 or Cmd+Shift+R).
**Q: I'm getting an error message**
A: Check that all required fields are filled out. Required fields are marked with asterisks (*).
**Q: The Tooling ID field won't accept letters**
A: The Tooling ID must be exactly 5 digits (numbers only). The system automatically prevents non-numeric input.
---
## 🆘 Need Help?
### Technical Problems
- **Browser issues**: Try refreshing or clearing cache
- **Login problems**: Contact your system administrator
- **Calculation errors**: Check that all required fields are completed
### Questions About Your Project
- **Mold specifications**: Ask your technical team
- **Cost factors**: Consult with your mold shop
- **Project requirements**: Speak with your design team
### Account Issues
- **Password reset**: Contact your system administrator
- **Access problems**: Contact your system administrator
- **Registration issues**: Contact your system administrator
---
## 💡 Pro Tips
1. **Be Accurate**: Enter precise information for the most accurate cost estimates
2. **Consult Experts**: When in doubt about technical specifications, consult with your technical team
3. **Keep Updated**: Ensure you're using the latest specifications and requirements
4. **Save Time**: Bookmark the calculator for quick access
5. **Double-Check**: Review your entries before calculating
---
*Need additional help? Contact your system administrator for support.*
+127
View File
@@ -0,0 +1,127 @@
from flask import Flask
import os
import logging
from datetime import datetime
from sqlalchemy.exc import SQLAlchemyError, OperationalError
from sqlalchemy import inspect
from time import sleep
from .config import get_config
from .logging_config import configure_logging
from dotenv import load_dotenv
from .extensions import db, login_manager, migrate, csrf, limiter, cache, mail
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_caching import Cache
# Load environment variables first
load_dotenv()
db = SQLAlchemy()
migrate = Migrate()
limiter = Limiter(key_func=get_remote_address)
cache = Cache()
def create_app(config_class=None):
app = Flask(__name__, static_folder='static')
@app.context_processor
def inject_now():
return {'now': datetime.utcnow()}
# Load configuration
if config_class is None:
config_class = get_config()
app.config.from_object(config_class)
# Ensure SECRET_KEY is set from environment if not present
if not hasattr(config_class, 'SECRET_KEY') or not config_class.SECRET_KEY:
env_secret = os.environ.get('SECRET_KEY')
if not env_secret:
raise ValueError("SECRET_KEY environment variable is not set")
config_class.SECRET_KEY = env_secret
app.config['SECRET_KEY'] = config_class.SECRET_KEY.encode('utf-8')
# Initialize configuration
config_class.init_app(app)
# Ensure instance folder exists
try:
os.makedirs(app.instance_path)
except OSError:
pass
# Configure logging
if os.environ.get('HOST_ENV') != 'AWS':
configure_logging(app)
# Initialize extensions with retry logic
max_retries = 3
retry_delay = 5
# Initialize extensions only if they haven't been initialized
if not hasattr(app, 'extensions') or 'sqlalchemy' not in app.extensions:
for attempt in range(max_retries):
try:
# Initialize SQLAlchemy only if not already initialized
if 'sqlalchemy' not in app.extensions:
db.init_app(app)
migrate.init_app(app, db)
# Initialize other extensions
if 'csrf' not in app.extensions:
csrf.init_app(app)
if 'limiter' not in app.extensions:
limiter.init_app(app)
if 'cache' not in app.extensions:
cache.init_app(app)
if 'mail' not in app.extensions:
mail.init_app(app)
if 'login_manager' not in app.extensions:
login_manager.init_app(app)
# Test database connection
with app.app_context():
db.engine.connect()
break
except OperationalError as e:
if attempt == max_retries - 1:
app.logger.error(f"Failed to connect to database after {max_retries} attempts: {str(e)}")
raise
app.logger.warning(f"Database connection attempt {attempt + 1} failed: {str(e)}")
sleep(retry_delay)
# Configure login manager
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'warning'
@login_manager.user_loader
def load_user(user_id):
from .models import User
return User.query.get(int(user_id))
# Register blueprints
from .routes import main, auth, admin
app.register_blueprint(main.bp)
app.register_blueprint(auth.bp)
app.register_blueprint(admin.bp)
# Register error handlers
from .errors import register_error_handlers
register_error_handlers(app)
# Debug: log all registered endpoints at startup (warning level and flush)
app.logger.warning("Registered endpoints (debug):")
for rule in app.url_map.iter_rules():
app.logger.warning(f"{rule.endpoint} {rule.methods} {rule.rule}")
for handler in app.logger.handlers:
handler.flush()
# Debug: write all registered endpoints to a file
# with open('logs/endpoints.log', 'w') as f:
# for rule in app.url_map.iter_rules():
# f.write(f"{rule.endpoint} {rule.methods} {rule.rule}\n")
return app
+228
View File
@@ -0,0 +1,228 @@
import os
from pathlib import Path
from datetime import timedelta
import logging
from logging.handlers import RotatingFileHandler
# Base directories
BASE_DIR = Path(__file__).parent.parent
INSTANCE_DIR = BASE_DIR / 'instance'
# Default values
DEFAULT_NET_BASE = 12.5
# Allowed emails for registration
ALLOWED_EMAILS = {
'jenny.tay@adidas.com',
'gema.yostisiano@adidas.com',
'dirgasapto.nugratama@adidas.com',
'test@example.com', # Original test email
'test1@example.com',
'test2@example.com',
'test3@example.com',
'test4@example.com',
'test5@example.com',
'test6@example.com',
'test7@example.com',
'test8@example.com',
'test9@example.com',
'test10@example.com'
}
# Tax rates by country
TAX_RATES = {
'China': 0.06,
'Vietnam': 0.10,
'Indonesia': 0.07
}
# Cost factors for calculations
COST_FACTORS = {
'mold': {
'Flat & Thin': 1.0,
'Flat & Thick': 1.25,
'Flat with Top/Heat Tip': 1.35,
'Top PL': 1.05,
'Short PL (Heat/Forefoot)': 1.18,
'Visible PL': 1.28,
'Wave PL': 1.32,
'2 Plates Mold': 1.0,
'3 Plates Mold': 1.35,
'Breathable + In-mold channel': 1.45,
'With Upper Bandage': 1.15,
'2 plate without Bandage': 1.05,
'Co-shot mold': 1.25,
'Foaming mold': 1.4
},
'complexity': {
'high_sidewall': 0.18,
'slider': 0.1,
'cavity': 0.12
},
'texture': {
'standard': 0.0,
'custom': 0.15,
'laser': 0.2
}
}
class Config:
"""Base configuration class."""
# Flask configuration
SECRET_KEY = os.getenv('SECRET_KEY') # Get SECRET_KEY directly from environment
FLASK_APP = 'app'
FLASK_ENV = 'development'
FLASK_DEBUG = True
# Security configuration
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
PERMANENT_SESSION_LIFETIME = timedelta(days=7)
REMEMBER_COOKIE_DURATION = timedelta(days=30)
REMEMBER_COOKIE_SECURE = True
REMEMBER_COOKIE_HTTPONLY = True
REMEMBER_COOKIE_SAMESITE = 'Lax'
# Database configuration
SQLALCHEMY_DATABASE_URI = None # Will be set from environment variable
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_size': 10,
'pool_recycle': 3600,
'pool_pre_ping': True
}
# Email configuration
MAIL_SERVER = None # Will be set from environment variable
MAIL_PORT = 587
MAIL_USE_TLS = True
MAIL_USERNAME = None # Will be set from environment variable
MAIL_PASSWORD = None # Will be set from environment variable
MAIL_DEFAULT_SENDER = None # Will be set from environment variable
# Admin account configuration
ADMIN_EMAIL = None # Will be set from environment variable
ADMIN_PASSWORD = None # Will be set from environment variable
# Logging configuration
LOG_LEVEL = 'INFO'
LOG_TO_STDOUT = True
LOG_FORMAT = '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
LOG_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
# Application config
ALLOWED_EMAILS = ALLOWED_EMAILS
TAX_RATES = TAX_RATES
DEFAULT_NET_BASE = DEFAULT_NET_BASE
WTF_CSRF_ENABLED = True
# Rate Limiting Configuration
RATELIMIT_DEFAULT = "200 per day;50 per hour;10 per minute"
RATELIMIT_STORAGE_URL = "memory://"
RATELIMIT_STRATEGY = "fixed-window"
RATELIMIT_HEADERS_ENABLED = True
RATELIMIT_HEADERS_RESET = True
RATELIMIT_HEADERS_RETRY_AFTER = True
# Cache Configuration
CACHE_TYPE = "SimpleCache"
CACHE_DEFAULT_TIMEOUT = 300
CACHE_THRESHOLD = 1000
@classmethod
def init_app(cls, app):
"""Initialize application with configuration."""
# Load configuration from environment variables
db_user = os.getenv('POSTGRES_USER')
db_password = os.getenv('POSTGRES_PASSWORD')
db_host = os.getenv('DATABASE_HOST')
db_name = os.getenv('POSTGRES_DB')
if db_user and db_password and db_host and db_name:
cls.SQLALCHEMY_DATABASE_URI = f"postgresql://{db_user}:{db_password}@{db_host}/{db_name}"
else:
cls.SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL') # Fallback for other setups
cls.MAIL_SERVER = os.getenv('MAIL_SERVER')
cls.MAIL_USERNAME = os.getenv('MAIL_USERNAME')
cls.MAIL_PASSWORD = os.getenv('MAIL_PASSWORD')
cls.MAIL_DEFAULT_SENDER = os.getenv('MAIL_DEFAULT_SENDER')
cls.ADMIN_EMAIL = os.getenv('ADMIN_EMAIL')
cls.ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD')
cls.LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO')
cls.LOG_TO_STDOUT = os.getenv('LOG_TO_STDOUT', 'True').lower() == 'true'
# Ensure SECRET_KEY is set
if not cls.SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is not set")
# Set SQLALCHEMY_DATABASE_URI on the app config
if not cls.SQLALCHEMY_DATABASE_URI:
raise ValueError("Database connection URI is not set. Please check DATABASE_URL or its components.")
app.config['SQLALCHEMY_DATABASE_URI'] = cls.SQLALCHEMY_DATABASE_URI
# Configure logging
if os.environ.get('HOST_ENV') != 'AWS':
if not os.path.exists('logs'):
os.mkdir('logs')
file_handler = RotatingFileHandler('logs/app.log',
maxBytes=10240,
backupCount=10)
file_handler.setFormatter(logging.Formatter(cls.LOG_FORMAT))
file_handler.setLevel(logging.INFO)
app.logger.addHandler(file_handler)
app.logger.setLevel(logging.INFO)
app.logger.info('Application startup')
class DevelopmentConfig(Config):
DEBUG = True
TESTING = False
REMEMBER_COOKIE_SECURE = False
SESSION_COOKIE_SECURE = False
SQLALCHEMY_ECHO = True
LOG_LEVEL = 'DEBUG'
WTF_CSRF_ENABLED = True
# Development-specific settings
RATELIMIT_DEFAULT = "1000 per day;100 per hour;20 per minute"
CACHE_TYPE = "SimpleCache"
class ProductionConfig(Config):
DEBUG = False
TESTING = False
REMEMBER_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SQLALCHEMY_ECHO = False
LOG_LEVEL = 'INFO'
WTF_CSRF_ENABLED = True
# Production-specific settings
CACHE_TYPE = "SimpleCache"
CACHE_DEFAULT_TIMEOUT = 300
class TestingConfig(Config):
TESTING = True
DEBUG = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
WTF_CSRF_ENABLED = False
LOG_LEVEL = 'DEBUG'
# Testing-specific settings
SECRET_KEY = 'testing-secret-key-not-used-in-production'
MAIL_SUPPRESS_SEND = True
RATELIMIT_ENABLED = False
RATELIMIT_DEFAULT = "1000 per day;100 per hour;20 per minute"
CACHE_TYPE = "SimpleCache"
SQLALCHEMY_ENGINE_OPTIONS = {}
def get_config():
"""Get the appropriate configuration class based on environment."""
config_name = os.getenv('FLASK_CONFIG', 'development')
config_map = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'testing': TestingConfig
}
return config_map.get(config_name, DevelopmentConfig)
+86
View File
@@ -0,0 +1,86 @@
from flask import render_template, jsonify, request, send_from_directory, current_app
import logging
import os
from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
logger = logging.getLogger(__name__)
def register_error_handlers(app):
@app.errorhandler(400)
def handle_400_error(e):
logger.error(f'400 error: {str(e)}')
if request.path.startswith('/calculate'):
return jsonify({'error': 'Invalid request parameters'}), 400
return render_template('errors/400.html'), 400
@app.errorhandler(403)
def handle_403_error(e):
logger.error(f'403 error: {str(e)}')
if request.path.startswith('/calculate'):
return jsonify({'error': 'Access forbidden'}), 403
return render_template('errors/403.html'), 403
@app.errorhandler(404)
def handle_404_error(e):
logger.error(f'404 error: {str(e)}')
if request.path.startswith('/calculate'):
return jsonify({'error': 'Resource not found'}), 404
if request.path.startswith('/static/'):
app.logger.warning(f'Static file not found: {request.path}')
return '', 404
return render_template('errors/404.html'), 404
@app.errorhandler(500)
def handle_500_error(e):
logger.error(f'500 error: {str(e)}')
app.logger.error(f'Server error: {str(e)}')
if request.path.startswith('/calculate'):
return jsonify({'error': 'Internal server error'}), 500
if request.path.startswith('/static/'):
app.logger.error(f'Error serving static file {request.path}: {str(e)}')
return '', 404
return render_template('errors/500.html'), 500
@app.errorhandler(OperationalError)
def handle_db_operational_error(error):
logger.error(f'Database operational error: {str(error)}', exc_info=True)
if request.path.startswith('/api/'):
return jsonify({
'error': 'Database connection error',
'message': 'Unable to connect to the database. Please try again later.'
}), 503
return render_template('errors/503.html',
message='Database connection error. Please try again later.'), 503
@app.errorhandler(IntegrityError)
def handle_db_integrity_error(error):
logger.error(f'Database integrity error: {str(error)}', exc_info=True)
if request.path.startswith('/api/'):
return jsonify({
'error': 'Database integrity error',
'message': 'The operation could not be completed due to data constraints.'
}), 409
return render_template('errors/409.html',
message='The operation could not be completed due to data constraints.'), 409
@app.errorhandler(SQLAlchemyError)
def handle_db_error(error):
logger.error(f'Database error: {str(error)}', exc_info=True)
if request.path.startswith('/api/'):
return jsonify({
'error': 'Database error',
'message': 'An unexpected database error occurred.'
}), 500
return render_template('errors/500.html',
message='An unexpected database error occurred.'), 500
@app.errorhandler(Exception)
def handle_unhandled_exception(e):
logger.error(f'Unhandled exception: {str(e)}', exc_info=True)
app.logger.error(f'Unhandled exception: {str(e)}', exc_info=True)
if request.path.startswith('/calculate'):
return jsonify({'error': 'Internal server error'}), 500
if request.path.startswith('/static/'):
app.logger.error(f'Error serving static file {request.path}: {str(e)}')
return '', 404
return render_template('errors/500.html'), 500
+16
View File
@@ -0,0 +1,16 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_wtf.csrf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_caching import Cache
from flask_mail import Mail
db = SQLAlchemy()
login_manager = LoginManager()
migrate = Migrate()
csrf = CSRFProtect()
limiter = Limiter(key_func=get_remote_address)
cache = Cache()
mail = Mail()
+19
View File
@@ -0,0 +1,19 @@
from .auth import LoginForm, RegistrationForm
from .quotation import QuotationForm
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo
from dotenv import load_dotenv
load_dotenv()
__all__ = ['LoginForm', 'RegistrationForm', 'QuotationForm']
class RequestPasswordResetForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('Password', validators=[DataRequired()])
password2 = PasswordField('Repeat Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')
+108
View File
@@ -0,0 +1,108 @@
from flask_wtf import FlaskForm
from wtforms import (
StringField,
PasswordField,
BooleanField,
SubmitField
)
from wtforms.validators import (
DataRequired,
Email,
Length,
ValidationError,
EqualTo
)
from sqlalchemy import func
from app.models import User
import re
class LoginForm(FlaskForm):
email = StringField('Email', validators=[
DataRequired(),
Email(),
Length(max=120)
])
password = PasswordField('Password', validators=[
DataRequired(),
Length(min=8)
])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Log In')
class RegistrationForm(FlaskForm):
username = StringField('Username', validators=[
DataRequired(),
Length(min=3, max=64)
])
email = StringField('Email', validators=[
DataRequired(),
Email(),
Length(max=120)
])
company = StringField('Company', validators=[
DataRequired(),
Length(max=100)
])
password = PasswordField('Password', validators=[
DataRequired(),
Length(min=8)
])
password_confirm = PasswordField('Confirm Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
terms = BooleanField('I agree to the terms and conditions', validators=[
DataRequired(message='You must agree to the terms and conditions')
])
submit = SubmitField('Register')
def validate_username(self, username):
if not re.match(r'^[a-zA-Z0-9_-]+$', username.data):
raise ValidationError('Username can only contain letters, numbers, underscores and hyphens')
user = User.query.filter_by(username=username.data).first()
if user:
raise ValidationError('Username already taken')
def validate_email(self, email):
user = User.query.filter_by(email=email.data).first()
if user:
raise ValidationError('Email already registered')
def validate_password(self, password):
if not re.search(r'[A-Z]', password.data):
raise ValidationError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', password.data):
raise ValidationError('Password must contain at least one lowercase letter')
if not re.search(r'[0-9]', password.data):
raise ValidationError('Password must contain at least one number')
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password.data):
raise ValidationError('Password must contain at least one special character')
class RequestPasswordResetForm(FlaskForm):
email = StringField('Email', validators=[
DataRequired(),
Email(),
Length(max=120)
])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[
DataRequired(),
Length(min=8)
])
password_confirm = PasswordField('Confirm New Password', validators=[
DataRequired(),
EqualTo('password', message='Passwords must match')
])
submit = SubmitField('Reset Password')
def validate_password(self, password):
if not re.search(r'[A-Z]', password.data):
raise ValidationError('Password must contain at least one uppercase letter')
if not re.search(r'[a-z]', password.data):
raise ValidationError('Password must contain at least one lowercase letter')
if not re.search(r'[0-9]', password.data):
raise ValidationError('Password must contain at least one number')
if not re.search(r'[!@#$%^&*(),.?":{}|<>]', password.data):
raise ValidationError('Password must contain at least one special character')
+193
View File
@@ -0,0 +1,193 @@
from flask_wtf import FlaskForm
from wtforms import (
StringField,
SelectField,
DateField,
SubmitField
)
from wtforms.validators import (
DataRequired,
Length,
ValidationError
)
def validate_tooling_id_format(form, field):
"""Custom validator to ensure tooling_id is exactly 5 digits"""
if not field.data.isdigit() or len(field.data) != 5:
raise ValidationError('Tooling ID must be exactly 5 digits')
class QuotationForm(FlaskForm):
# Factory Information
mold_shop_name = StringField('Mold Shop Name', validators=[DataRequired()])
mold_shop_country = SelectField('Mold Shop Country', validators=[DataRequired()], choices=[
('', 'Select Country'),
('China', 'China'),
('Vietnam', 'Vietnam'),
('Indonesia', 'Indonesia')
])
t1_shoe_factory_name = StringField('T1 Shoe Factory Name', validators=[DataRequired()])
t1_shoe_factory_country = SelectField('T1 Shoe Factory Country', validators=[DataRequired()], choices=[
('', 'Select Country'),
('China', 'China'),
('Vietnam', 'Vietnam'),
('Indonesia', 'Indonesia')
])
t2_component_factory_name = StringField('T2 Component Factory Name', validators=[DataRequired()])
t2_component_factory_country = SelectField('T2 Component Factory Country', validators=[DataRequired()], choices=[
('', 'Select Country'),
('China', 'China'),
('Vietnam', 'Vietnam'),
('Indonesia', 'Indonesia')
])
# Mold Information
model_name = StringField('Model Name', validators=[DataRequired()])
tooling_id = StringField('Tooling ID', validators=[DataRequired(), Length(min=5, max=5), validate_tooling_id_format], id='tooling_id')
season = StringField('Season', validators=[DataRequired()])
# Define stage-type mapping
STAGE_TYPE_MAPPING = {
'CR0': ['Sample'],
'CR1': ['Sample'],
'CR2': ['Sample'],
'SMS': ['A Set'],
'commercialization': ['A Set'],
'production': ['Additional']
}
# Define the mapping of main types to sub-types
MOLD_TYPE_MAPPING = {
'Rubber': ['Flat Outsole', 'Cupsole', 'Rubber Sole'],
'CMEVA': ['2 Plates Mold', '3 Plates Mold', '2 Plates with in-mold channel', 'Breathable + in-mold channel'],
'IMEVA': ['2 Plates Mold', '3 Plates Mold'],
'Sandal': ['Sandal with Upper Bandage', '2 Plate without Upper Bandage'],
'Co-shot mold': ['Co-shot mold', 'Foaming mold'],
'PU': ['2 Plates Mold', '3 Plates Mold']
}
# Define the mapping of sub-types to their specific options
MOLD_SPECIFIC_TYPE_OPTIONS = {
'Flat Outsole': ['Flat', 'Flat with Top/Heel Tip'],
'Cupsole': ['Top PL', 'Visible PL'],
'Rubber Sole': ['Top PL', 'Short PL (Heel/Forefoot)', 'Visible PL', 'Wave PL']
}
mold_main_type = SelectField('Mold Main Type', validators=[DataRequired()], choices=[
('', 'Select Main Type'),
('Rubber', 'Rubber'),
('CMEVA', 'CMEVA'),
('IMEVA', 'IMEVA'),
('Sandal', 'Sandal'),
('Co-shot mold', 'Co-shot mold'),
('PU', 'PU')
], id='mold_main_type')
mold_sub_type = SelectField('Mold Sub Type', validators=[DataRequired()], choices=[
('', 'Select Sub Type')
], id='mold_sub_type')
mold_specific_type = SelectField('Mold Specific Type', validators=[], choices=[
('', 'Select Specific Type')
], id='mold_specific_type', coerce=str, default='')
stage = SelectField('Stage', validators=[DataRequired()], choices=[
('', 'Select Stage'),
('CR0', 'CR0'),
('CR1', 'CR1'),
('CR2', 'CR2'),
('SMS', 'SMS'),
('commercialization', 'Commercialization'),
('production', 'Production')
], id='stage')
type = SelectField('Type', validators=[DataRequired()], choices=[
('', 'Select Type'),
('Sample', 'Sample'),
('A Set', 'A Set'),
('Additional', 'Additional')
], id='type')
# Dates and Approvals
issue_date = DateField('Issue Date', validators=[DataRequired()])
approved_by_mold_shop = StringField('Approved by (Mold Shop Representative)', validators=[DataRequired()])
approved_by_t1_tooling = StringField('Approved by (T1 Tooling Representative)', validators=[DataRequired()])
approved_by_lo_tooling = StringField('Approved by (LO Tooling)', validators=[DataRequired()])
# Mold Cost
sliders_count = SelectField('Number of Sliders', validators=[DataRequired()], choices=[
('', 'Select Number of Sliders'),
('0', '0'),
('1', '1'),
('2', '2'),
('3', '3'),
('4', '4')
])
cavity_count = SelectField('Number of Cavities', validators=[DataRequired()], choices=[
('', 'Select Number of Pairs'),
('0.5', '0.5 pair'),
('1', '1 pair'),
('1.5', '1.5 pair'),
('2', '2 pair'),
('2.5', '2.5 pair'),
('3', '3 pair')
])
digital_texture_type = SelectField('Digital Texture', validators=[DataRequired()], choices=[
('', 'Select Texture Type'),
('adidas_digital', 'adidas digital texture'),
('customized texture', 'customized texture'),
('laser', 'laser texture')
])
complexity_high_sidewall = SelectField('High Sidewall >60mm', validators=[DataRequired()], choices=[('no', 'No'), ('yes', 'Yes')])
submit = SubmitField('Calculate')
def __init__(self, *args, **kwargs):
super(QuotationForm, self).__init__(*args, **kwargs)
# Set initial choices for mold_sub_type based on default mold_main_type
if self.mold_main_type.data in self.MOLD_TYPE_MAPPING:
self.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in self.MOLD_TYPE_MAPPING[self.mold_main_type.data]]
def validate_mold_sub_type(self, field):
main_type = self.mold_main_type.data
print(f"Validating sub_type: {field.data} for main_type: {main_type}")
if main_type and main_type in self.MOLD_TYPE_MAPPING:
valid_sub_types = self.MOLD_TYPE_MAPPING[main_type]
print(f"Valid sub_types for {main_type}: {valid_sub_types}")
if field.data not in valid_sub_types and field.data != '':
print(f"Invalid sub_type: {field.data}. Valid types: {valid_sub_types}")
raise ValidationError('Invalid sub-type for selected main type')
def validate_mold_specific_type(self, field):
"""Validate that the selected specific type is valid for the selected sub type."""
sub_type = self.mold_sub_type.data
if not sub_type:
field.data = ''
return
# Get valid specific types for the selected sub type
valid_specific_types = self.MOLD_SPECIFIC_TYPE_OPTIONS.get(sub_type, [])
# If there are no specific types for this sub-type, the field should be empty
if not valid_specific_types:
field.data = '' # Always clear the field if it shouldn't have a value
return
# If there are specific types, validate the selection
if field.data is None or field.data == '':
raise ValidationError(f'Specific type is required for {sub_type}')
if field.data not in valid_specific_types:
raise ValidationError(f'Invalid specific type for {sub_type}. Valid options are: {", ".join(valid_specific_types)}')
def validate_type(self, field):
stage = self.stage.data
print(f"Validating type: {field.data} for stage: {stage}")
if stage and stage in self.STAGE_TYPE_MAPPING:
valid_types = self.STAGE_TYPE_MAPPING[stage]
print(f"Valid types for {stage}: {valid_types}")
if field.data not in valid_types and field.data != '':
print(f"Invalid type: {field.data}")
raise ValidationError(f'Invalid type for {stage} stage. Allowed types: {", ".join(valid_types)}')
elif field.data != '':
raise ValidationError('Please select a stage first')
def validate_tooling_id(self, field):
validate_tooling_id_format(self, field)
+126
View File
@@ -0,0 +1,126 @@
import os
import psutil
import time
from datetime import datetime
from flask import jsonify
from sqlalchemy import text
from . import db
def check_database():
"""Check database connection and response time."""
try:
start_time = time.time()
# Execute a simple query
db.session.execute(text('SELECT 1'))
response_time = (time.time() - start_time) * 1000 # Convert to milliseconds
return {
'status': 'healthy',
'response_time_ms': round(response_time, 2)
}
except Exception as e:
return {
'status': 'unhealthy',
'error': str(e)
}
def check_disk_space():
"""Check available disk space."""
try:
disk = psutil.disk_usage('/')
return {
'status': 'healthy' if disk.percent < 90 else 'warning',
'total_gb': round(disk.total / (1024**3), 2),
'used_gb': round(disk.used / (1024**3), 2),
'free_gb': round(disk.free / (1024**3), 2),
'percent_used': disk.percent
}
except Exception as e:
return {
'status': 'unhealthy',
'error': str(e)
}
def check_memory():
"""Check memory usage."""
try:
memory = psutil.virtual_memory()
return {
'status': 'healthy' if memory.percent < 90 else 'warning',
'total_gb': round(memory.total / (1024**3), 2),
'used_gb': round(memory.used / (1024**3), 2),
'free_gb': round(memory.available / (1024**3), 2),
'percent_used': memory.percent
}
except Exception as e:
return {
'status': 'unhealthy',
'error': str(e)
}
def check_cpu():
"""Check CPU usage."""
try:
cpu_percent = psutil.cpu_percent(interval=1)
return {
'status': 'healthy' if cpu_percent < 90 else 'warning',
'usage_percent': cpu_percent,
'core_count': psutil.cpu_count()
}
except Exception as e:
return {
'status': 'unhealthy',
'error': str(e)
}
def check_application():
"""Check application-specific metrics."""
try:
# Check if required directories exist
required_dirs = ['instance', 'logs', 'backups']
dir_status = {}
for dir_name in required_dirs:
dir_status[dir_name] = os.path.exists(dir_name)
# Check if log files are writable
log_status = {}
log_files = ['logs/mold_cost_calculator.log', 'logs/error.log', 'logs/access.log']
for log_file in log_files:
log_status[log_file] = os.access(os.path.dirname(log_file), os.W_OK)
return {
'status': 'healthy' if all(dir_status.values()) and all(log_status.values()) else 'warning',
'directories': dir_status,
'log_files': log_status,
'startup_time': datetime.utcnow().isoformat()
}
except Exception as e:
return {
'status': 'unhealthy',
'error': str(e)
}
def get_health_status():
"""Get comprehensive health status of the application."""
checks = {
'database': check_database(),
'disk_space': check_disk_space(),
'memory': check_memory(),
'cpu': check_cpu(),
'application': check_application()
}
# Determine overall status
statuses = [check['status'] for check in checks.values()]
if 'unhealthy' in statuses:
overall_status = 'unhealthy'
elif 'warning' in statuses:
overall_status = 'warning'
else:
overall_status = 'healthy'
return {
'status': overall_status,
'timestamp': datetime.utcnow().isoformat(),
'checks': checks
}
+96
View File
@@ -0,0 +1,96 @@
import os
import logging
from logging.handlers import RotatingFileHandler, SMTPHandler
from pathlib import Path
def configure_logging(app):
"""Configure logging for the application."""
# Create logs directory if it doesn't exist
log_dir = Path('logs')
log_dir.mkdir(exist_ok=True)
# Set up basic logging configuration
logging.basicConfig(
level=app.config['LOG_LEVEL'],
format=app.config['LOG_FORMAT'],
datefmt='%Y-%m-%d %H:%M:%S'
)
# Configure application logger
app.logger.setLevel(app.config['LOG_LEVEL'])
# Remove default handlers
for handler in app.logger.handlers[:]:
app.logger.removeHandler(handler)
# Add file handler for application logs
file_handler = RotatingFileHandler(
'logs/mold_cost_calculator.log',
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=10,
encoding='utf-8'
)
file_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
file_handler.setLevel(app.config['LOG_LEVEL'])
app.logger.addHandler(file_handler)
# Add file handler for error logs
error_handler = RotatingFileHandler(
'logs/error.log',
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=10,
encoding='utf-8'
)
error_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
error_handler.setLevel(logging.ERROR)
app.logger.addHandler(error_handler)
# Add file handler for access logs
access_handler = RotatingFileHandler(
'logs/access.log',
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=10,
encoding='utf-8'
)
access_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
access_handler.setLevel(logging.INFO)
app.logger.addHandler(access_handler)
# Add console handler if LOG_TO_STDOUT is set
if app.config.get('LOG_TO_STDOUT'):
console_handler = logging.StreamHandler()
console_handler.setFormatter(logging.Formatter(app.config['LOG_FORMAT']))
console_handler.setLevel(app.config['LOG_LEVEL'])
app.logger.addHandler(console_handler)
# Add email handler for critical errors in production
if not app.debug and not app.testing and app.config.get('MAIL_SERVER'):
mail_handler = SMTPHandler(
mailhost=(app.config['MAIL_SERVER'], app.config['MAIL_PORT']),
fromaddr=app.config['MAIL_DEFAULT_SENDER'],
toaddrs=[app.config['ADMIN_EMAIL']],
subject='Mold Cost Calculator Error'
)
mail_handler.setLevel(logging.ERROR)
mail_handler.setFormatter(logging.Formatter('''
Message type: %(levelname)s
Location: %(pathname)s:%(lineno)d
Module: %(module)s
Function: %(funcName)s
Time: %(asctime)s
Message:
%(message)s
'''))
app.logger.addHandler(mail_handler)
# Configure SQLAlchemy logging
if app.config.get('SQLALCHEMY_ECHO'):
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
# Configure Werkzeug logging
logging.getLogger('werkzeug').setLevel(logging.INFO)
# Log startup message
app.logger.info('Mold Cost Calculator startup')
return app.logger
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class CostFactor(db.Model):
"""Model for storing cost factors that can be managed via admin interface"""
id = db.Column(db.Integer, primary_key=True)
category = db.Column(db.String(50), nullable=False) # 'mold', 'complexity', 'texture', 'tax'
subcategory = db.Column(db.String(50)) # For mold types: 'Rubber', 'CMEVA', etc.
name = db.Column(db.String(100), nullable=False) # Factor name
factor_value = db.Column(db.Float, nullable=False) # The actual factor value
calculation_formula = db.Column(db.String(200)) # For complexity factors
percentage = db.Column(db.String(20)) # For display purposes
description = db.Column(db.String(500))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_by = db.Column(db.Integer, db.ForeignKey('user.id'))
# Relationship
updater = db.relationship('User', backref='cost_factor_updates')
def __repr__(self):
return f'<CostFactor {self.category}:{self.name}={self.factor_value}>'
class CostFactorHistory(db.Model):
"""Model for tracking changes to cost factors"""
id = db.Column(db.Integer, primary_key=True)
cost_factor_id = db.Column(db.Integer, db.ForeignKey('cost_factor.id'), nullable=False)
old_value = db.Column(db.Float)
new_value = db.Column(db.Float, nullable=False)
changed_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
change_reason = db.Column(db.String(500))
# Relationships
cost_factor = db.relationship('CostFactor', backref='history')
user = db.relationship('User', backref='cost_factor_changes')
def __repr__(self):
return f'<CostFactorHistory {self.cost_factor_id}:{self.old_value}->{self.new_value}>'
+6
View File
@@ -0,0 +1,6 @@
from .. import db
from .user import User
from .quotation import Quotation
from .cost_factor import CostFactor, CostFactorHistory
__all__ = ['db', 'User', 'Quotation', 'CostFactor', 'CostFactorHistory']
+66
View File
@@ -0,0 +1,66 @@
from datetime import datetime, timezone
from sqlalchemy import event
from .. import db
class CostFactor(db.Model):
"""Model for storing cost factors that can be managed via admin interface"""
__tablename__ = 'cost_factors'
id = db.Column(db.Integer, primary_key=True)
category = db.Column(db.String(50), nullable=False) # 'mold', 'complexity', 'texture', 'tax'
subcategory = db.Column(db.String(50)) # For mold types: 'Rubber', 'CMEVA', etc.
name = db.Column(db.String(100), nullable=False) # Factor name
factor_value = db.Column(db.Float, nullable=False) # The actual factor value
calculation_formula = db.Column(db.String(200)) # For complexity factors
percentage = db.Column(db.String(20)) # For display purposes
description = db.Column(db.String(500))
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
updated_by = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'))
# Relationship
updater = db.relationship('User', backref='cost_factor_updates')
__table_args__ = (
db.Index('ix_cost_factor_category', category),
db.Index('ix_cost_factor_name', name),
db.Index('ix_cost_factor_active', is_active),
)
def __repr__(self):
return f'<CostFactor {self.category}:{self.name}={self.factor_value}>'
class CostFactorHistory(db.Model):
"""Model for tracking changes to cost factors"""
__tablename__ = 'cost_factor_history'
id = db.Column(db.Integer, primary_key=True)
cost_factor_id = db.Column(db.Integer, db.ForeignKey('cost_factors.id'), nullable=False)
old_value = db.Column(db.Float)
new_value = db.Column(db.Float, nullable=False)
changed_by = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'), nullable=False)
changed_at = db.Column(db.DateTime, default=datetime.utcnow)
change_reason = db.Column(db.String(500))
# Relationships
cost_factor = db.relationship('CostFactor', backref='history')
user = db.relationship('User', backref='cost_factor_changes')
__table_args__ = (
db.Index('ix_cost_factor_history_factor_id', cost_factor_id),
db.Index('ix_cost_factor_history_changed_at', changed_at),
)
def __repr__(self):
return f'<CostFactorHistory {self.cost_factor_id}:{self.old_value}->{self.new_value}>'
# Event listeners for model changes
@event.listens_for(CostFactor, 'before_insert')
def set_cost_factor_timestamps(mapper, connection, target):
target.created_at = datetime.now(timezone.utc)
target.updated_at = datetime.now(timezone.utc)
@event.listens_for(CostFactor, 'before_update')
def set_cost_factor_update_timestamp(mapper, connection, target):
target.updated_at = datetime.now(timezone.utc)
+63
View File
@@ -0,0 +1,63 @@
from datetime import datetime, timezone
from sqlalchemy import event
from .. import db
class Quotation(db.Model):
__tablename__ = 'mold_quotations'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('enterprise_users.id'), nullable=False)
# Factory Information
mold_shop_name = db.Column(db.String(100), nullable=False)
mold_shop_country = db.Column(db.String(50), nullable=False)
t1_shoe_factory_name = db.Column(db.String(100), nullable=False)
t1_shoe_factory_country = db.Column(db.String(50), nullable=False)
t2_component_factory_name = db.Column(db.String(100), nullable=False)
t2_component_factory_country = db.Column(db.String(50), nullable=False)
# Mold Information
model_name = db.Column(db.String(100), nullable=False)
tooling_id = db.Column(db.String(5), nullable=False)
season = db.Column(db.String(10), nullable=False)
stage = db.Column(db.String(10), nullable=False)
type = db.Column(db.String(20), nullable=False)
# Dates and Approvals
issue_date = db.Column(db.Date, nullable=False)
approved_by_mold_shop = db.Column(db.String(100), nullable=False)
approved_by_t1_tooling = db.Column(db.String(100), nullable=False)
approved_by_lo_tooling = db.Column(db.String(100), nullable=False)
# Mold Specifications
mold_main_type = db.Column(db.String(50), nullable=False)
mold_sub_type = db.Column(db.String(50), nullable=False)
mold_specific_type = db.Column(db.String(50), nullable=False)
complexity_high_sidewall = db.Column(db.Boolean, default=False)
sliders_count = db.Column(db.Integer, nullable=False)
cavity_count = db.Column(db.Float, nullable=False)
digital_texture_type = db.Column(db.String(50), nullable=False)
# Calculation Results
base_price = db.Column(db.Float, nullable=False)
tax_rate = db.Column(db.Float)
total_cost = db.Column(db.Float, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
is_archived = db.Column(db.Boolean, default=False)
__table_args__ = (
db.Index('ix_quotation_user_id', user_id),
db.Index('ix_quotation_created_at', created_at),
db.Index('ix_quotation_issue_date', issue_date),
)
def __repr__(self):
return f'<Quotation {self.id}>'
# Event listeners for model changes
@event.listens_for(Quotation, 'before_insert')
def set_quotation_timestamps(mapper, connection, target):
target.created_at = datetime.now(timezone.utc)
+116
View File
@@ -0,0 +1,116 @@
from datetime import datetime, timezone, timedelta
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin
from sqlalchemy import event, func
from .. import db
import secrets
import logging
logger = logging.getLogger(__name__)
class User(UserMixin, db.Model):
__tablename__ = 'enterprise_users'
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(25), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False)
company = db.Column(db.String(100), nullable=False)
password_hash = db.Column(db.String(128), nullable=False)
created_at = db.Column(db.DateTime)
password_changed_at = db.Column(db.DateTime)
is_admin = db.Column(db.Boolean, default=False)
last_login = db.Column(db.DateTime)
is_active = db.Column(db.Boolean, default=True)
failed_login_attempts = db.Column(db.Integer, default=0)
last_failed_login = db.Column(db.DateTime)
reset_token = db.Column(db.String(100), unique=True)
reset_token_expires = db.Column(db.DateTime)
# Relationships
quotations = db.relationship('Quotation', backref='user', lazy=True)
def set_password(self, password):
try:
self.password_hash = generate_password_hash(password, method='pbkdf2:sha256')
self.password_changed_at = datetime.now(timezone.utc)
self.reset_token = None
self.reset_token_expires = None
except Exception as e:
logger.error(f'Error setting password: {str(e)}', exc_info=True)
raise
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def generate_reset_token(self):
token = secrets.token_urlsafe(16) # Shorter token for display
self.reset_token = token
self.reset_token_expires = datetime.now(timezone.utc) + timedelta(minutes=15) # 15-minute expiry
return token
def verify_reset_token(self, token):
if not self.reset_token or not self.reset_token_expires:
return False
if datetime.now(timezone.utc) > self.reset_token_expires:
return False
return secrets.compare_digest(self.reset_token, token)
@classmethod
def authenticate(cls, email, password):
user = cls.query.filter(func.lower(cls.email) == email.lower()).first()
if user and user.check_password(password):
if not user.is_active:
return None
user.failed_login_attempts = 0
user.last_failed_login = None
db.session.commit()
return user
if user:
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
user.last_failed_login = datetime.now(timezone.utc)
db.session.commit()
return None
@classmethod
def create(cls, username, email, company, password):
try:
logger.info(f'Checking if username {username} exists...')
if cls.query.filter(func.lower(cls.username) == username.lower()).first():
logger.warning(f'Username {username} already taken')
raise ValueError('Username already taken')
logger.info(f'Checking if email {email} exists...')
if cls.query.filter(func.lower(cls.email) == email.lower()).first():
logger.warning(f'Email {email} already registered')
raise ValueError('Email already registered')
logger.info('Creating new user...')
user = cls(
username=username,
email=email,
company=company,
is_active=True,
failed_login_attempts=0,
is_admin=False
)
logger.info('Setting password...')
user.set_password(password)
logger.info('Adding user to session...')
db.session.add(user)
logger.info('User created successfully')
return user
except Exception as e:
logger.error(f'Error creating user: {str(e)}', exc_info=True)
raise
def __repr__(self):
return f'<User {self.username}>'
# Event listeners for model changes
@event.listens_for(User, 'before_insert')
def set_timestamps(mapper, connection, target):
target.created_at = datetime.now(timezone.utc)
target.password_changed_at = datetime.now(timezone.utc)
+473
View File
@@ -0,0 +1,473 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, jsonify, make_response
from flask_login import login_required, current_user
from app import create_app, db
from ..forms import QuotationForm
from ..models import User, Quotation, CostFactor, CostFactorHistory
from datetime import datetime, timedelta, timezone
from sqlalchemy import desc, text
import logging
from app import limiter, cache
bp = Blueprint('admin', __name__, url_prefix='/admin')
@bp.before_request
def before_request():
if not current_user.is_authenticated or not current_user.is_admin:
flash('Access denied. Admin privileges required.', 'danger')
return redirect(url_for('main.home'))
@bp.route('/dashboard')
@login_required
@limiter.limit("30 per minute")
@cache.cached(timeout=60, key_prefix='admin_dashboard')
def dashboard():
try:
# Get statistics
total_users = User.query.count()
total_quotations = Quotation.query.count()
recent_quotations = Quotation.query.order_by(desc(Quotation.created_at)).limit(5).all()
# Get user statistics
active_users = User.query.filter(
User.last_login >= datetime.utcnow() - timedelta(days=30)
).count()
# Get quotation statistics by country (using mold_shop_country)
country_stats = db.session.query(
Quotation.mold_shop_country,
db.func.count(Quotation.id)
).group_by(Quotation.mold_shop_country).all()
return render_template('admin/dashboard.html',
total_users=total_users,
total_quotations=total_quotations,
active_users=active_users,
recent_quotations=recent_quotations,
country_stats=country_stats
)
except Exception as e:
current_app.logger.error(f'Admin dashboard error: {str(e)}')
flash('An error occurred while loading the dashboard.', 'error')
return redirect(url_for('main.home'))
@bp.route('/users')
@login_required
@limiter.limit("30 per minute")
@cache.cached(timeout=60, key_prefix='admin_users')
def users():
try:
page = request.args.get('page', 1, type=int)
per_page = 20
users = User.query.order_by(User.created_at.desc()).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('admin/users.html', users=users)
except Exception as e:
current_app.logger.error(f'Admin users error: {str(e)}')
flash('An error occurred while loading users.', 'error')
return redirect(url_for('admin.dashboard'))
@bp.route('/quotations')
@login_required
@limiter.limit("30 per minute")
def quotations():
try:
page = request.args.get('page', 1, type=int)
per_page = 20
# Get filter parameters
country = request.args.get('country')
mold_type = request.args.get('mold_type')
date_from_str = request.args.get('date_from')
date_to_str = request.args.get('date_to')
# Convert date strings to datetime objects
date_from = None
date_to = None
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
try:
if date_from_str:
date_from = datetime.strptime(date_from_str, '%Y-%m-%d')
if date_to_str:
date_to = datetime.strptime(date_to_str, '%Y-%m-%d')
except ValueError:
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
date_from = None
date_to = None
# Build query
query = Quotation.query
if country:
query = query.filter(Quotation.mold_shop_country == country)
if mold_type:
query = query.filter(Quotation.mold_main_type == mold_type)
if date_from:
query = query.filter(Quotation.created_at >= date_from)
if date_to:
# Add one day to include the end date fully
next_day = date_to + timedelta(days=1)
query = query.filter(Quotation.created_at < next_day)
# Get unique countries and mold types for filters
countries = db.session.query(Quotation.mold_shop_country).distinct().all()
countries = [c[0] for c in countries if c[0]]
mold_types = db.session.query(Quotation.mold_main_type).distinct().all()
mold_types = [t[0] for t in mold_types if t[0]]
quotations = query.order_by(desc(Quotation.created_at)).paginate(
page=page, per_page=per_page, error_out=False
)
return render_template('admin/quotations.html',
quotations=quotations,
countries=sorted(countries),
mold_types=sorted(mold_types),
today=today,
filters={
'country': country,
'mold_type': mold_type,
'date_from': date_from,
'date_to': date_to
}
)
except Exception as e:
current_app.logger.error(f'Admin quotations error: {str(e)}')
flash('An error occurred while loading quotations.', 'error')
return redirect(url_for('admin.dashboard'))
@bp.route('/user/<int:user_id>')
@login_required
def user_detail(user_id):
try:
user = User.query.get_or_404(user_id)
quotations = Quotation.query.filter_by(user_id=user_id).order_by(
desc(Quotation.created_at)
).all()
return render_template('admin/user_detail.html',
user=user,
quotations=quotations
)
except Exception as e:
current_app.logger.error(f'Admin user detail error: {str(e)}')
flash('An error occurred while loading user details.', 'error')
return redirect(url_for('admin.users'))
@bp.route('/quotation/<int:id>')
@login_required
@limiter.limit("30 per minute")
@cache.cached(timeout=60, key_prefix='admin_quotation_detail')
def quotation_detail(id):
try:
quotation = Quotation.query.get_or_404(id)
return render_template('admin/quotation_detail.html',
quotation=quotation
)
except Exception as e:
current_app.logger.error(f'Admin quotation detail error: {str(e)}')
flash('An error occurred while loading quotation details.', 'error')
return redirect(url_for('admin.quotations'))
@bp.route('/user/<int:user_id>/toggle', methods=['POST'])
@login_required
def toggle_user(user_id):
try:
user = User.query.get_or_404(user_id)
user.is_active = not user.is_active
db.session.commit()
return jsonify({
'status': 'success',
'is_active': user.is_active
})
except Exception as e:
current_app.logger.error(f'Admin toggle user error: {str(e)}')
db.session.rollback()
return jsonify({
'status': 'error',
'message': 'An error occurred while updating user status'
}), 500
@bp.route('/delete_user', methods=['POST'])
@login_required
@limiter.limit("10 per minute")
def delete_user():
if not request.is_json:
return jsonify({'error': 'Invalid request format'}), 400
data = request.get_json()
user_id = data.get('user_id')
if not user_id:
return jsonify({'error': 'User ID is required'}), 400
try:
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
return jsonify({'error': 'Cannot delete your own account'}), 400
db.session.delete(user)
db.session.commit()
cache.delete_memoized(users)
current_app.logger.info(f'User {user.email} deleted by admin {current_user.email}')
return jsonify({'message': 'User deleted successfully'})
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error deleting user: {str(e)}')
return jsonify({'error': 'An error occurred while deleting the user'}), 500
@bp.route('/delete_quotation', methods=['POST'])
@login_required
@limiter.limit("10 per minute")
def delete_quotation():
if not request.is_json:
return jsonify({'error': 'Invalid request format'}), 400
data = request.get_json()
quotation_id = data.get('quotation_id')
if not quotation_id:
return jsonify({'error': 'Quotation ID is required'}), 400
try:
quotation = Quotation.query.get_or_404(quotation_id)
db.session.delete(quotation)
db.session.commit()
cache.delete_memoized(quotations)
current_app.logger.info(f'Quotation {quotation.id} deleted by admin {current_user.email}')
return jsonify({'message': 'Quotation deleted successfully'})
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error deleting quotation: {str(e)}')
return jsonify({'error': 'An error occurred while deleting the quotation'}), 500
@bp.route('/cost-factors')
@login_required
@limiter.limit("30 per minute")
def cost_factors():
"""Display and manage cost factors"""
try:
from ..utils.cost_factor_loader import initialize_default_cost_factors
from flask import current_app
# Add debug logging
current_app.logger.info("Accessing cost_factors route")
# Verify database connection
try:
db.session.execute(text('SELECT 1'))
except Exception as e:
current_app.logger.error(f"Database connection error: {str(e)}")
flash('Database connection error', 'error')
return redirect(url_for('admin.dashboard'))
# Initialize default factors if none exist
try:
initialize_default_cost_factors()
except Exception as e:
current_app.logger.error(f"Error initializing default cost factors: {str(e)}")
flash('Error initializing cost factors', 'error')
return redirect(url_for('admin.dashboard'))
# Get all cost factors grouped by category
try:
mold_factors = CostFactor.query.filter_by(category='mold', is_active=True).order_by(CostFactor.name).all()
complexity_factors = CostFactor.query.filter_by(category='complexity', is_active=True).order_by(CostFactor.name).all()
texture_factors = CostFactor.query.filter_by(category='texture', is_active=True).order_by(CostFactor.name).all()
tax_factors = CostFactor.query.filter_by(category='tax', is_active=True).order_by(CostFactor.name).all()
base_factors = CostFactor.query.filter_by(category='base', is_active=True).order_by(CostFactor.name).all()
except Exception as e:
current_app.logger.error(f"Error querying cost factors: {str(e)}")
flash('Error loading cost factors from database', 'error')
return redirect(url_for('admin.dashboard'))
# Log the number of factors found
current_app.logger.info(f"Found factors - Mold: {len(mold_factors)}, Complexity: {len(complexity_factors)}, "
f"Texture: {len(texture_factors)}, Tax: {len(tax_factors)}, Base: {len(base_factors)}")
# Log individual factors for debugging
current_app.logger.debug("Base Factors: %s", [f"{f.name}: {f.factor_value}" for f in base_factors])
current_app.logger.debug("Mold Factors: %s", [f"{f.name}: {f.factor_value}" for f in mold_factors])
current_app.logger.debug("Complexity Factors: %s", [f"{f.name}: {f.factor_value}" for f in complexity_factors])
current_app.logger.debug("Texture Factors: %s", [f"{f.name}: {f.factor_value}" for f in texture_factors])
current_app.logger.debug("Tax Factors: %s", [f"{f.name}: {f.factor_value}" for f in tax_factors])
# Prepare template context
template_context = {
'mold_factors': mold_factors,
'complexity_factors': complexity_factors,
'texture_factors': texture_factors,
'tax_factors': tax_factors,
'base_factors': base_factors
}
# Log template context
current_app.logger.debug("Template context: %s", template_context)
try:
response = make_response(render_template(
'admin/cost_factors.html',
**template_context
))
except Exception as e:
current_app.logger.error(f"Template rendering error: {str(e)}")
flash('Error rendering the page', 'error')
return redirect(url_for('admin.dashboard'))
# Add headers to prevent caching
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
except Exception as e:
current_app.logger.error(f"Unhandled error in cost_factors route: {str(e)}")
flash('An unexpected error occurred while loading cost factors.', 'error')
return redirect(url_for('admin.dashboard'))
@bp.route('/cost-factors/edit/<int:factor_id>', methods=['GET', 'POST'])
@login_required
@limiter.limit("30 per minute")
def edit_cost_factor(factor_id):
"""Edit a specific cost factor"""
try:
factor = CostFactor.query.get_or_404(factor_id)
if request.method == 'POST':
old_value = factor.factor_value
new_value = float(request.form.get('factor_value'))
change_reason = request.form.get('change_reason', '')
# Update the factor
factor.factor_value = new_value
factor.updated_by = current_user.id
# Create history record
history = CostFactorHistory(
cost_factor_id=factor.id,
old_value=old_value,
new_value=new_value,
changed_by=current_user.id,
change_reason=change_reason
)
db.session.add(history)
db.session.commit()
# Clear the cache
cache.delete_memoized(cost_factors)
cache.delete('admin_cost_factors')
flash(f'Cost factor "{factor.name}" updated successfully.', 'success')
return redirect(url_for('admin.cost_factors'))
return render_template('admin/edit_cost_factor.html', factor=factor)
except Exception as e:
current_app.logger.error(f'Admin edit cost factor error: {str(e)}')
flash('An error occurred while editing the cost factor.', 'error')
return redirect(url_for('admin.cost_factors'))
@bp.route('/cost-factors/history/<int:factor_id>')
@login_required
@limiter.limit("30 per minute")
def cost_factor_history(factor_id):
"""View history of changes for a cost factor"""
try:
factor = CostFactor.query.get_or_404(factor_id)
history = CostFactorHistory.query.filter_by(cost_factor_id=factor_id)\
.order_by(CostFactorHistory.changed_at.desc()).all()
return render_template('admin/cost_factor_history.html',
factor=factor,
history=history
)
except Exception as e:
current_app.logger.error(f'Admin cost factor history error: {str(e)}')
flash('An error occurred while loading cost factor history.', 'error')
return redirect(url_for('admin.cost_factors'))
@bp.route('/cost-factors/reset', methods=['POST'])
@login_required
@limiter.limit("10 per minute")
def reset_cost_factors():
"""Reset cost factors to default values"""
try:
from ..utils.cost_factor_loader import get_default_cost_factors
# Get default values
default_factors, default_tax_rates, default_base = get_default_cost_factors()
# Create a mapping of all default values
default_values = {}
# Add mold factors
for name, value in default_factors['mold'].items():
default_values[name] = value
# Add complexity factors
for name, value in default_factors['complexity'].items():
default_values[name] = value
# Add texture factors
for name, value in default_factors['texture'].items():
default_values[name] = value
# Add tax factors
for name, value in default_tax_rates.items():
default_values[name] = value
# Add base factor
default_values['DEFAULT_NET_BASE'] = default_base
# Update existing cost factors to default values
for factor in CostFactor.query.all():
if factor.name in default_values:
old_value = factor.factor_value
new_value = default_values[factor.name]
if old_value != new_value: # Only update if value changed
factor.factor_value = new_value
factor.updated_by = current_user.id
# Create history record for the reset
history = CostFactorHistory(
cost_factor_id=factor.id,
old_value=old_value,
new_value=new_value,
changed_by=current_user.id,
change_reason='Reset to default value'
)
db.session.add(history)
db.session.commit()
# Clear the cache
cache.delete_memoized(cost_factors)
cache.delete('admin_cost_factors')
flash('Cost factors have been reset to default values.', 'success')
return redirect(url_for('admin.cost_factors'))
except Exception as e:
current_app.logger.error(f'Admin reset cost factors error: {str(e)}')
db.session.rollback()
flash('An error occurred while resetting cost factors.', 'error')
return redirect(url_for('admin.cost_factors'))
@bp.route('/debug-endpoints')
def debug_endpoints():
from flask import current_app
with open('logs/endpoints.log', 'w') as f:
for rule in current_app.url_map.iter_rules():
f.write(f"{rule.endpoint} {rule.methods} {rule.rule}\n")
return 'Endpoints written to logs/endpoints.log', 200
+171
View File
@@ -0,0 +1,171 @@
from flask import Blueprint, render_template, redirect, url_for, request, flash, current_app
from flask_login import login_user, logout_user, login_required, current_user
from app.forms import LoginForm, RegistrationForm, RequestPasswordResetForm, ResetPasswordForm
from app import create_app, db
from app import limiter
from datetime import datetime, timezone
from app.config import ALLOWED_EMAILS
import logging
from flask_mail import Message
from app import mail
from app.models import User
bp = Blueprint('auth', __name__)
logger = logging.getLogger(__name__)
@bp.route('/login', methods=['GET', 'POST'])
@limiter.limit("5 per minute")
def login():
if current_user.is_authenticated:
return redirect(url_for('main.calculator'))
form = LoginForm()
if form.validate_on_submit():
try:
email = form.email.data.strip().lower()
password = form.password.data
# Log the login attempt
current_app.logger.info(f'Login attempt for email: {email}')
user = User.authenticate(email, password)
if user:
login_user(user, remember=form.remember_me.data)
user.last_login = datetime.now(timezone.utc)
db.session.commit()
current_app.logger.info(f'User {user.email} logged in successfully')
next_page = request.args.get('next') or url_for('main.calculator')
return redirect(next_page)
else:
current_app.logger.warning(f'Failed login attempt for email: {email}')
flash('Invalid email or password', 'danger')
except Exception as e:
current_app.logger.error(f'Login error: {str(e)}', exc_info=True)
flash('An error occurred during login. Please try again.', 'danger')
return render_template('auth/login.html', form=form)
@bp.route('/register', methods=['GET', 'POST'])
@limiter.limit(lambda: None if current_app.config.get('DEBUG', False) else "10 per hour")
def register():
try:
if current_user.is_authenticated:
return redirect(url_for('main.home'))
form = RegistrationForm()
logger.info('Registration form initialized')
if form.validate_on_submit():
try:
email = form.email.data.strip().lower()
username = form.username.data.strip().lower()
company = form.company.data.strip().upper()
logger.info(f'Processing registration for email: {email}, username: {username}, company: {company}')
# Check if email is in allowed list or has adidas.com domain
if email not in ALLOWED_EMAILS and not email.endswith('@adidas.com'):
logger.warning(f'Registration attempt with non-allowed email: {email}')
flash('Registration is not allowed for this email domain', 'danger')
return render_template('auth/register.html', form=form)
try:
logger.info('Creating new user...')
user = User.create(
username=username,
email=email,
company=company,
password=form.password.data
)
logger.info('User created, committing to database...')
db.session.commit()
logger.info(f'New user registered successfully: {user.email}')
flash('Registration successful! Please login', 'success')
return redirect(url_for('auth.login'))
except ValueError as e:
logger.warning(f'Registration validation error: {str(e)}')
flash(str(e), 'danger')
except Exception as e:
db.session.rollback()
logger.error(f'Registration error: {str(e)}', exc_info=True)
flash('An error occurred during registration', 'danger')
except Exception as e:
logger.error(f'Unexpected error during registration: {str(e)}', exc_info=True)
flash('An unexpected error occurred', 'danger')
elif form.errors:
logger.warning(f'Form validation errors: {form.errors}')
for field, errors in form.errors.items():
for error in errors:
flash(f'{field}: {error}', 'danger')
logger.warning(f'Field {field} validation error: {error}')
logger.info('Rendering registration template')
return render_template('auth/register.html', form=form)
except Exception as e:
logger.error(f'Critical error in registration route: {str(e)}', exc_info=True)
flash('A critical error occurred', 'danger')
return render_template('auth/register.html', form=form)
@bp.route('/logout')
@login_required
def logout():
current_app.logger.info(f'User {current_user.email} logged out')
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('main.home'))
@bp.route('/request-reset', methods=['GET', 'POST'])
@limiter.limit("3 per hour")
def request_reset():
if current_user.is_authenticated:
return redirect(url_for('main.calculator'))
form = RequestPasswordResetForm()
if form.validate_on_submit():
try:
email = form.email.data.strip().lower()
user = User.query.filter_by(email=email).first()
if user:
token = user.generate_reset_token()
db.session.commit()
reset_url = url_for('auth.reset_password', token=token, _external=True)
flash(f'Your password reset link is: {reset_url}', 'info')
flash('This link will expire in 15 minutes.', 'warning')
return redirect(url_for('auth.login'))
# Always show success message to prevent email enumeration
flash('If an account exists with that email, you will receive password reset instructions.', 'info')
return redirect(url_for('auth.login'))
except Exception as e:
current_app.logger.error(f'Error generating reset token: {str(e)}', exc_info=True)
flash('An error occurred while processing your request.', 'danger')
return render_template('auth/request_reset.html', form=form)
@bp.route('/reset-password/<token>', methods=['GET', 'POST'])
@limiter.limit("3 per hour")
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.calculator'))
form = ResetPasswordForm()
if form.validate_on_submit():
try:
user = User.query.filter_by(reset_token=token).first()
if not user or not user.verify_reset_token(token):
flash('The password reset link is invalid or has expired.', 'danger')
return redirect(url_for('auth.request_reset'))
user.set_password(form.password.data)
db.session.commit()
current_app.logger.info(f'Password reset successful for user {user.email}')
flash('Your password has been reset. You can now log in.', 'success')
return redirect(url_for('auth.login'))
except Exception as e:
current_app.logger.error(f'Error resetting password: {str(e)}', exc_info=True)
flash('An error occurred while resetting your password.', 'danger')
return render_template('auth/reset_password.html', form=form)
+383
View File
@@ -0,0 +1,383 @@
from flask import Blueprint, render_template, redirect, url_for, request, jsonify, current_app, flash
from flask_login import current_user, login_required, login_user, logout_user
from app.models import Quotation, User
from app import db
from app.forms import LoginForm, QuotationForm
from app.utils.cost_factor_loader import get_cost_factors
from app.config import ALLOWED_EMAILS
import logging
from datetime import datetime, timezone, timedelta
from app import limiter, cache
import json
from ..utils.calculator import calculate_base_price
from ..health import get_health_status
bp = Blueprint('main', __name__)
logger = logging.getLogger(__name__)
@bp.route('/', methods=['GET', 'POST'])
def home():
if current_user.is_authenticated:
return redirect(url_for('main.calculator'))
return redirect(url_for('auth.login'))
@bp.route('/calculator')
@login_required
def calculator():
try:
form = QuotationForm()
# Set today's date as default for issue_date
if not form.issue_date.data:
form.issue_date.data = datetime.now(timezone.utc).date()
# Get cost factors for the form
cost_factors, tax_rates, default_net_base = get_cost_factors()
# Initialize form with default values
if form.mold_main_type.data in form.MOLD_TYPE_MAPPING:
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[form.mold_main_type.data]]
# Get quote history with error handling
try:
quote_history = Quotation.query.filter_by(user_id=current_user.id)\
.order_by(Quotation.created_at.desc())\
.limit(5)\
.all()
except Exception as e:
current_app.logger.error(f"Error fetching quote history: {str(e)}")
quote_history = []
return render_template('calculator.html',
form=form,
quote_history=quote_history,
tax_rates=tax_rates,
quotation=None)
except Exception as e:
current_app.logger.error(f"Calculator error: {str(e)}", exc_info=True)
flash('Error loading calculator data', 'danger')
# Create a new form with default values
form = QuotationForm()
if form.mold_main_type.data in form.MOLD_TYPE_MAPPING:
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[form.mold_main_type.data]]
# Set today's date as default even in error case
form.issue_date.data = datetime.now(timezone.utc).date()
return render_template('calculator.html',
form=form,
quote_history=[],
tax_rates={},
quotation=None)
@bp.route('/calculate', methods=['GET', 'POST'])
@login_required
@limiter.limit("30 per minute")
@cache.cached(timeout=300, key_prefix='calculate_form')
def calculate():
"""Calculate mold cost based on form data"""
try:
form = QuotationForm()
# Log incoming request data
current_app.logger.info('Incoming request data:')
current_app.logger.info(f'Request method: {request.method}')
current_app.logger.info(f'Request form data: {dict(request.form)}')
current_app.logger.info(f'Request JSON data: {request.get_json(silent=True)}')
if request.method == 'POST':
try:
# Log form data before validation
current_app.logger.info('Form data before validation:')
current_app.logger.info(f'Form data: {form.data}')
current_app.logger.info(f'Form errors: {form.errors}')
# Log the selected mold type
current_app.logger.info(f'Selected mold_main_type: {form.mold_main_type.data}')
current_app.logger.info(f'Selected mold_sub_type: {form.mold_sub_type.data}')
current_app.logger.info(f'Selected mold_specific_type: {form.mold_specific_type.data}')
# Update mold_sub_type choices based on selected mold_main_type
main_type = request.form.get('mold_main_type')
current_app.logger.info(f'Updating sub_type choices for main_type: {main_type}')
if main_type in form.MOLD_TYPE_MAPPING:
form.mold_sub_type.choices = [('', 'Select Sub Type')] + [(x, x) for x in form.MOLD_TYPE_MAPPING[main_type]]
current_app.logger.info(f'Updated sub_type choices: {form.mold_sub_type.choices}')
# Update mold_specific_type choices based on selected mold_sub_type
sub_type = request.form.get('mold_sub_type')
if sub_type in form.MOLD_SPECIFIC_TYPE_OPTIONS:
form.mold_specific_type.choices = [('', 'Select Specific Type')] + [(x, x) for x in form.MOLD_SPECIFIC_TYPE_OPTIONS[sub_type]]
else:
form.mold_specific_type.choices = [('', 'Select Specific Type')]
# Check if specific type is needed
if sub_type and sub_type in form.MOLD_SPECIFIC_TYPE_OPTIONS:
# If specific type is needed but not provided, set it to empty string
if not request.form.get('mold_specific_type'):
form.mold_specific_type.data = ''
else:
# If specific type is not needed, set it to empty string
form.mold_specific_type.data = ''
if not form.validate_on_submit():
current_app.logger.warning('Form validation failed:')
current_app.logger.warning(f'Form errors: {form.errors}')
current_app.logger.warning(f'Form data after validation: {form.data}')
current_app.logger.warning(f'Request form data: {dict(request.form)}')
return jsonify({
'success': False,
'error': 'Invalid form data',
'message': 'Please check the form for errors',
'errors': form.errors,
'form_data': dict(request.form)
}), 400
# Get form data
form_data = form.data
current_app.logger.info(f'Processing form data: {form_data}')
# Calculate costs
try:
result = calculate_base_price(
mold_main_type=form.mold_main_type.data,
mold_sub_type=form.mold_sub_type.data,
mold_specific_type=form.mold_specific_type.data,
sliders_count=int(form.sliders_count.data),
cavity_count=float(form.cavity_count.data),
digital_texture_type=form.digital_texture_type.data,
complexity_high_sidewall=(form.complexity_high_sidewall.data == 'yes')
)
current_app.logger.info(f'Calculation result: {result}')
# Get cost factors for tax rate
cost_factors, tax_rates, default_net_base = get_cost_factors()
# Get tax rate for the country
tax_rate = tax_rates.get(form.mold_shop_country.data, 0.0)
# Calculate total cost (base price + tax)
total_cost = result['total_cost'] * (1 + tax_rate)
return jsonify({
'success': True,
'base_price': float(result['base_price']),
'complexity_cost': float(result['complexity_cost']),
'texture_cost': float(result['texture_cost']),
'tax_rate': float(tax_rate),
'total_cost': float(total_cost),
'mold_shop_name': form.mold_shop_name.data,
'mold_shop_country': form.mold_shop_country.data,
't1_shoe_factory_name': form.t1_shoe_factory_name.data,
't1_shoe_factory_country': form.t1_shoe_factory_country.data,
't2_component_factory_name': form.t2_component_factory_name.data,
't2_component_factory_country': form.t2_component_factory_country.data,
'model_name': form.model_name.data,
'tooling_id': form.tooling_id.data,
'season': form.season.data,
'stage': form.stage.data,
'type': form.type.data,
'issue_date': form.issue_date.data.strftime('%Y-%m-%d'),
'approved_by_mold_shop': form.approved_by_mold_shop.data,
'approved_by_t1_tooling': form.approved_by_t1_tooling.data,
'approved_by_lo_tooling': form.approved_by_lo_tooling.data,
'mold_main_type': form.mold_main_type.data,
'mold_sub_type': form.mold_sub_type.data,
'mold_specific_type': form.mold_specific_type.data,
'complexity_high_sidewall': 'yes' if form.complexity_high_sidewall.data == 'yes' else 'no',
'sliders_count': form.sliders_count.data,
'cavity_count': form.cavity_count.data,
'digital_texture_type': form.digital_texture_type.data
})
except Exception as calc_error:
current_app.logger.error(f'Error calculating costs: {str(calc_error)}', exc_info=True)
return jsonify({
'success': False,
'error': 'Calculation error',
'message': str(calc_error)
}), 500
except Exception as form_error:
current_app.logger.error(f'Error processing form: {str(form_error)}', exc_info=True)
return jsonify({
'success': False,
'error': 'Form processing error',
'message': str(form_error)
}), 500
# GET request - render the form
return render_template('calculator.html', form=form)
except Exception as e:
current_app.logger.error(f'Unexpected error in calculate route: {str(e)}', exc_info=True)
return jsonify({
'success': False,
'error': 'Unexpected error',
'message': str(e)
}), 500
@bp.route('/quotations')
@login_required
@limiter.limit("30 per minute")
@cache.cached(timeout=60, key_prefix='user_quotations')
def quotations():
quotations = Quotation.query.filter_by(user_id=current_user.id)\
.order_by(Quotation.created_at.desc()).all()
return render_template('quotations.html', quotations=quotations)
@bp.route('/quotation/<int:id>')
@login_required
@limiter.limit("30 per minute")
@cache.cached(timeout=60, key_prefix='quotation_detail')
def quotation_detail(id):
quotation = Quotation.query.get_or_404(id)
if quotation.user_id != current_user.id and not current_user.is_admin:
flash('Access denied.', 'danger')
return redirect(url_for('main.quotations'))
return render_template('quotation_detail.html', quotation=quotation)
@bp.route('/health')
def health_check():
"""Health check endpoint that returns the status of various system components."""
# Get health status
health_status = get_health_status()
# Set appropriate status code based on overall health
status_code = 200 if health_status['status'] == 'healthy' else 503
# Add rate limiting headers
response = jsonify(health_status)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response, status_code
@bp.route('/api/recent-quotes')
@login_required
@limiter.limit("30 per minute")
def recent_quotes():
try:
recent_quotes = Quotation.query.filter_by(user_id=current_user.id)\
.order_by(Quotation.created_at.desc())\
.limit(5)\
.all()
return jsonify({
'success': True,
'quotes': [{
'id': quote.id,
'model_name': quote.model_name,
'tooling_id': quote.tooling_id,
'mold_sub_type': quote.mold_sub_type,
'cavity_count': float(quote.cavity_count),
'sliders_count': quote.sliders_count,
'created_at': quote.created_at.strftime('%Y-%m-%d %H:%M'),
'mold_shop_country': quote.mold_shop_country,
'digital_texture_type': quote.digital_texture_type,
'season': quote.season,
'stage': quote.stage,
'base_price': float(quote.base_price),
'tax_rate': float(quote.tax_rate) if quote.tax_rate is not None else 0.0,
'total_cost': float(quote.total_cost)
} for quote in recent_quotes]
})
except Exception as e:
current_app.logger.error(f'Error fetching recent quotes: {str(e)}', exc_info=True)
return jsonify({
'success': False,
'error': 'An error occurred while fetching recent quotes',
'quotes': []
}), 500
@bp.route('/save-quotation', methods=['POST'])
@login_required
@limiter.limit("30 per minute")
def save_quotation():
"""Save a quotation to the database"""
try:
# Get form data
form_data = request.get_json()
if not form_data:
return jsonify({
'success': False,
'error': 'No data provided'
}), 400
# Check for duplicate submission (same user, same data within last 30 seconds)
try:
recent_duplicate = Quotation.query.filter(
Quotation.user_id == current_user.id,
Quotation.model_name == form_data.get('model_name'),
Quotation.tooling_id == form_data.get('tooling_id'),
Quotation.mold_main_type == form_data.get('mold_main_type'),
Quotation.mold_sub_type == form_data.get('mold_sub_type'),
Quotation.created_at >= datetime.now(timezone.utc) - timedelta(seconds=30)
).first()
if recent_duplicate:
current_app.logger.warning(f'Duplicate submission detected for user {current_user.id}, tooling_id {form_data.get("tooling_id")}')
return jsonify({
'success': False,
'error': 'Duplicate submission',
'message': 'This quotation was already submitted recently. Please wait a moment before trying again.',
'quotation_id': recent_duplicate.id
}), 409
except Exception as duplicate_check_error:
current_app.logger.error(f'Error checking for duplicates: {str(duplicate_check_error)}')
# Continue with submission even if duplicate check fails
# Ensure mold_specific_type is not empty or missing
mold_specific_type = form_data.get('mold_specific_type')
if not mold_specific_type:
mold_specific_type = 'N/A'
# Create new quotation
quotation = Quotation(
user_id=current_user.id,
mold_shop_name=form_data.get('mold_shop_name'),
mold_shop_country=form_data.get('mold_shop_country'),
t1_shoe_factory_name=form_data.get('t1_shoe_factory_name'),
t1_shoe_factory_country=form_data.get('t1_shoe_factory_country'),
t2_component_factory_name=form_data.get('t2_component_factory_name'),
t2_component_factory_country=form_data.get('t2_component_factory_country'),
model_name=form_data.get('model_name'),
tooling_id=form_data.get('tooling_id'),
season=form_data.get('season'),
stage=form_data.get('stage'),
type=form_data.get('type'),
issue_date=datetime.strptime(form_data.get('issue_date'), '%Y-%m-%d').date(),
approved_by_mold_shop=form_data.get('approved_by_mold_shop'),
approved_by_t1_tooling=form_data.get('approved_by_t1_tooling'),
approved_by_lo_tooling=form_data.get('approved_by_lo_tooling'),
mold_main_type=form_data.get('mold_main_type'),
mold_sub_type=form_data.get('mold_sub_type'),
mold_specific_type=mold_specific_type,
complexity_high_sidewall=(form_data.get('complexity_high_sidewall') == 'yes'),
sliders_count=int(form_data.get('sliders_count')),
cavity_count=float(form_data.get('cavity_count')),
digital_texture_type=form_data.get('digital_texture_type'),
base_price=float(form_data.get('base_price')),
tax_rate=float(form_data.get('tax_rate')),
total_cost=float(form_data.get('total_cost'))
)
# Save to database
db.session.add(quotation)
db.session.commit()
current_app.logger.info(f'Quotation saved with ID: {quotation.id}')
return jsonify({
'success': True,
'quotation_id': quotation.id,
'message': 'Quotation saved successfully'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error saving quotation: {str(e)}', exc_info=True)
return jsonify({
'success': False,
'error': 'Failed to save quotation',
'message': str(e)
}), 500
+197
View File
@@ -0,0 +1,197 @@
/* Admin Panel Global Styles */
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--info-color: #17a2b8;
--warning-color: #ffc107;
--danger-color: #dc3545;
--light-color: #f8f9fa;
--dark-color: #343a40;
}
/* Layout */
.admin-container {
padding: 1.5rem;
}
.admin-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 1.5rem;
}
.admin-card-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid rgba(0,0,0,0.1);
background-color: var(--light-color);
border-radius: 8px 8px 0 0;
}
.admin-card-body {
padding: 1.5rem;
}
/* Tables */
.admin-table {
width: 100%;
margin-bottom: 1rem;
background-color: transparent;
}
.admin-table th,
.admin-table td {
padding: 0.75rem;
vertical-align: middle;
border-top: 1px solid #dee2e6;
}
.admin-table thead th {
vertical-align: bottom;
border-bottom: 2px solid #dee2e6;
background-color: var(--light-color);
font-weight: 600;
}
/* Forms */
.admin-form-group {
margin-bottom: 1rem;
}
.admin-form-control {
display: block;
width: 100%;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
color: #495057;
background-color: #fff;
border: 1px solid #ced4da;
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
/* Buttons */
.admin-btn {
display: inline-block;
font-weight: 400;
text-align: center;
vertical-align: middle;
user-select: none;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
border-radius: 0.25rem;
transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
}
.admin-btn-primary {
color: #fff;
background-color: var(--primary-color);
border: 1px solid var(--primary-color);
}
.admin-btn-secondary {
color: #fff;
background-color: var(--secondary-color);
border: 1px solid var(--secondary-color);
}
.admin-btn-info {
color: #fff;
background-color: var(--info-color);
border: 1px solid var(--info-color);
}
.admin-btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
line-height: 1.5;
border-radius: 0.2rem;
}
/* Stats Cards */
.admin-stats-card {
padding: 1.5rem;
border-radius: 8px;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 1rem;
}
.admin-stats-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.admin-stats-number {
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.admin-stats-label {
color: var(--secondary-color);
font-size: 0.875rem;
}
/* Filters */
.admin-filters {
margin-bottom: 1.5rem;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.admin-filter-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
align-items: end;
}
/* Pagination */
.admin-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.5rem;
margin-top: 1.5rem;
}
.admin-pagination-link {
padding: 0.375rem 0.75rem;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
color: var(--primary-color);
text-decoration: none;
}
.admin-pagination-link:hover {
background-color: var(--light-color);
}
.admin-pagination-info {
color: var(--secondary-color);
padding: 0.375rem 0.75rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.admin-filter-form {
grid-template-columns: 1fr;
}
.admin-table {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
}
+2078
View File
File diff suppressed because it is too large Load Diff
+7
View File
File diff suppressed because one or more lines are too long
+91
View File
@@ -0,0 +1,91 @@
/* Calculator Page Styles */
.container-main {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.calculator-card {
background-color: #ffffff;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.section-header {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
border-left: 4px solid #0d6efd;
}
.section-content {
background-color: #ffffff;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
margin-bottom: 2rem;
}
.form-label {
color: #495057;
font-weight: 500;
}
.form-select, .form-control {
border: 1px solid #ced4da;
}
.form-select:focus, .form-control:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
/* Table Styles */
.table {
margin-bottom: 0;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
.table td {
vertical-align: middle;
}
/* Button Styles */
.btn-primary {
background-color: #0d6efd;
border-color: #0d6efd;
font-weight: 500;
}
.btn-primary:hover {
background-color: #0b5ed7;
border-color: #0a58ca;
}
/* Form Text */
.form-text {
color: #6c757d;
font-size: 0.875rem;
margin-top: 0.25rem;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.container-main {
margin: 1rem auto;
}
.section-content {
padding: 1rem;
}
.col-md-3 {
margin-bottom: 1rem;
}
}
+144
View File
@@ -0,0 +1,144 @@
:root {
--primary-color: #2563eb;
--secondary-color: #3b82f6;
--success-color: #22c55e;
--warning-color: #f59e0b;
--error-color: #ef4444;
--text-primary: #1e293b;
--text-secondary: #64748b;
--bg-light: #f8fafc;
}
/* Base Styles */
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
color: var(--text-primary);
background-color: var(--bg-light);
line-height: 1.6;
}
.container-main {
max-width: 1280px;
margin: 2rem auto;
padding: 0 1rem;
}
/* Responsive Navigation */
.navbar-brand {
font-weight: 600;
letter-spacing: -0.025em;
}
.nav-link {
transition: all 0.2s ease;
padding: 0.5rem 1rem !important;
border-radius: 0.375rem;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* Calculator Form */
.calculator-card {
background: white;
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 2rem;
margin: 2rem 0;
}
.input-group-3d {
position: relative;
}
.input-group-3d input,
.input-group-3d select {
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 0.875rem 1rem;
transition: all 0.2s ease;
width: 100%;
}
.input-group-3d input:focus,
.input-group-3d select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
/* Result Panel */
.result-card {
border-left: 4px solid var(--success-color);
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 2rem;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Mobile Optimization */
@media (max-width: 768px) {
.calculator-card {
margin: 1rem -0.5rem;
border-radius: 0;
}
.navbar-collapse {
margin-top: 1rem;
padding: 1rem;
background: var(--primary-color);
border-radius: 0.5rem;
}
}
建议配合以下CSS样式添加到base模板中
.input-group-text {
transition: opacity 0.3s ease;
}
.progress-bar {
transition: width 0.5s ease, background-color 0.3s ease;
}
/* Cost Breakdown Visual Enhancement */
.cost-breakdown {
background: #fff;
border-radius: 1rem;
box-shadow: 0 2px 12px 0 rgba(37,99,235,0.08);
padding: 2rem 1.5rem 1.5rem 1.5rem;
margin-bottom: 2rem;
}
.cost-breakdown .cost-item {
border: 1.5px solid #e0e7ef;
box-shadow: 0 1px 4px 0 rgba(37,99,235,0.04);
transition: box-shadow 0.2s, border-color 0.2s;
margin-bottom: 0.5rem;
}
.cost-breakdown .cost-item:hover {
border-color: var(--primary-color);
box-shadow: 0 4px 16px 0 rgba(37,99,235,0.10);
background: #f1f5fd;
}
.cost-breakdown .h4.text-primary {
font-weight: 700;
letter-spacing: 0.5px;
}
.cost-breakdown .small.text-muted {
font-size: 0.98rem;
color: var(--text-secondary);
}
.cost-breakdown .total-cost {
background: linear-gradient(90deg, #e0e7ef 0%, #f8fafc 100%);
border-width: 2.5px;
box-shadow: 0 2px 8px 0 rgba(37,99,235,0.10);
}
.cost-breakdown .total-cost span {
letter-spacing: 1.5px;
}
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background circle -->
<circle cx="16" cy="16" r="16" fill="#2C3E50"/>
<!-- Calculator body -->
<rect x="8" y="6" width="16" height="20" rx="2" fill="#3498DB"/>
<!-- Calculator screen -->
<rect x="10" y="8" width="12" height="4" rx="1" fill="#ECF0F1"/>
<!-- Calculator buttons -->
<rect x="10" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="15" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="20" y="14" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="10" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="15" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="20" y="19" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="10" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="15" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
<rect x="20" y="24" width="3" height="3" rx="1" fill="#ECF0F1"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2607
View File
File diff suppressed because one or more lines are too long
+638
View File
@@ -0,0 +1,638 @@
// static/js/mold_calculator.js
// Check if script is already initialized
if (window.moldCalculatorInitialized) {
console.log('Mold calculator already initialized, skipping...');
} else {
window.moldCalculatorInitialized = true;
// Wait for DOM to be fully loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCalculator);
} else {
initializeCalculator();
}
}
function initializeCalculator() {
console.log('DOM Content Loaded - Initializing mold calculator');
// Get form elements
const form = document.getElementById('calcForm');
const mainSelect = document.getElementById('mold_main_type');
const subSelect = document.getElementById('mold_sub_type');
const specificTypeSelect = document.getElementById('mold_specific_type');
const stageSelect = document.getElementById('stage');
const typeSelect = document.getElementById('type');
const toolingIdInput = document.getElementById('tooling_id');
const submitButton = document.getElementById('calculateBtn');
console.log('Main select element:', mainSelect);
console.log('Sub select element:', subSelect);
console.log('Specific type select element:', specificTypeSelect);
console.log('Stage select element:', stageSelect);
console.log('Type select element:', typeSelect);
console.log('Tooling ID input:', toolingIdInput);
console.log('Submit button:', submitButton);
// Check if submit button exists
if (!submitButton) {
console.error('Submit button not found! Looking for element with ID "calculateBtn"');
// Try alternative selectors
const alternativeButton = form.querySelector('button[type="submit"]');
if (alternativeButton) {
console.log('Found submit button using alternative selector:', alternativeButton);
} else {
console.error('No submit button found with any selector!');
return; // Exit initialization if we can't find the submit button
}
}
// Define mappings
const moldTypeMapping = {
'Rubber': ['Flat Outsole', 'Cupsole', 'Rubber Sole'],
'CMEVA': ['2 Plates Mold', '3 Plates Mold', '2 Plates with in-mold channel', 'Breathable + in-mold channel'],
'IMEVA': ['2 Plates Mold', '3 Plates Mold'],
'Sandal': ['Sandal with Upper Bandage', '2 Plate without Upper Bandage'],
'Co-shot mold': ['Co-shot mold', 'Foaming mold'],
'PU': ['2 Plates Mold', '3 Plates Mold']
};
const specificTypeOptions = {
'Flat Outsole': ['Flat', 'Flat with Top/Heel Tip'],
'Cupsole': ['Top PL', 'Visible PL'],
'Rubber Sole': ['Top PL', 'Short PL (Heel/Forefoot)', 'Visible PL', 'Wave PL']
};
const stageTypeMapping = {
'CR0': ['Sample'],
'CR1': ['Sample'],
'CR2': ['Sample'],
'SMS': ['A Set'],
'commercialization': ['A Set'],
'production': ['Additional']
};
// Function to update sub-type options
function updateSubTypeOptions() {
console.log('Populating sub-types');
const selectedType = mainSelect.value;
console.log('Selected type:', selectedType);
// Clear current options
subSelect.innerHTML = '<option value="">Select Sub Type</option>';
if (selectedType && moldTypeMapping[selectedType]) {
console.log('Found mapping for type:', selectedType);
const options = moldTypeMapping[selectedType];
console.log('Adding options:', options);
options.forEach(option => {
const opt = document.createElement('option');
opt.value = option;
opt.textContent = option;
subSelect.appendChild(opt);
});
}
// Reset specific type
specificTypeSelect.innerHTML = '<option value="">Select Specific Type</option>';
specificTypeSelect.disabled = true;
}
// Function to update specific type options
function updateSpecificTypeOptions() {
const selectedSubType = subSelect.value;
console.log('Selected sub-type:', selectedSubType);
// Clear current options
specificTypeSelect.innerHTML = '<option value="">Select Specific Type</option>';
if (selectedSubType && specificTypeOptions[selectedSubType]) {
console.log('Found specific type options for:', selectedSubType);
const options = specificTypeOptions[selectedSubType];
console.log('Adding specific type options:', options);
options.forEach(option => {
const opt = document.createElement('option');
opt.value = option;
opt.textContent = option;
specificTypeSelect.appendChild(opt);
});
specificTypeSelect.disabled = false;
} else {
specificTypeSelect.disabled = true;
}
}
// Function to update type options based on stage
function updateTypeOptions() {
const selectedStage = stageSelect.value;
console.log('Selected stage:', selectedStage);
// Clear current options
typeSelect.innerHTML = '<option value="">Select Type</option>';
if (selectedStage && stageTypeMapping[selectedStage]) {
const options = stageTypeMapping[selectedStage];
console.log('Adding type options:', options);
options.forEach(option => {
const opt = document.createElement('option');
opt.value = option;
opt.textContent = option;
typeSelect.appendChild(opt);
});
// Enable the type select
typeSelect.disabled = false;
} else {
// Disable the type select if no stage is selected
typeSelect.disabled = true;
}
}
// Add event listeners
mainSelect.addEventListener('change', function() {
console.log('Main type changed:', this.value);
updateSubTypeOptions();
});
subSelect.addEventListener('change', function() {
console.log('Sub type changed:', this.value);
updateSpecificTypeOptions();
});
stageSelect.addEventListener('change', function() {
console.log('Stage changed:', this.value);
updateTypeOptions();
});
// Add tooling ID validation
if (toolingIdInput) {
console.log('Tooling ID input found, adding validation');
toolingIdInput.addEventListener('input', function(e) {
console.log('Tooling ID input event triggered, value:', this.value);
// Remove any non-numeric characters
let value = this.value.replace(/\D/g, '');
// Limit to 5 digits
if (value.length > 5) {
value = value.substring(0, 5);
}
this.value = value;
console.log('Tooling ID value after validation:', this.value);
});
toolingIdInput.addEventListener('paste', function(e) {
console.log('Tooling ID paste event triggered');
e.preventDefault();
const pastedText = (e.clipboardData || window.clipboardData).getData('text');
const numericOnly = pastedText.replace(/\D/g, '').substring(0, 5);
this.value = numericOnly;
console.log('Tooling ID value after paste validation:', this.value);
});
} else {
console.error('Tooling ID input element not found!');
// Try alternative selectors
const alternativeSelectors = [
'input[name="tooling_id"]',
'input[placeholder*="tooling"]',
'input[placeholder*="Tooling"]'
];
for (let selector of alternativeSelectors) {
const element = document.querySelector(selector);
if (element) {
console.log('Found tooling ID input using selector:', selector, element);
break;
}
}
}
// Initialize options
updateSubTypeOptions();
updateTypeOptions();
// Function to reset form
function resetForm(form) {
console.log('Resetting form...');
// Reset all select elements to their first option
const selects = form.querySelectorAll('select');
selects.forEach(select => {
select.selectedIndex = 0;
select.dispatchEvent(new Event('change'));
});
// Reset all input elements
const inputs = form.querySelectorAll('input');
inputs.forEach(input => {
input.value = '';
});
// Reset all checkboxes and radio buttons
const checkboxes = form.querySelectorAll('input[type="checkbox"], input[type="radio"]');
checkboxes.forEach(checkbox => {
checkbox.checked = false;
});
// Disable dependent dropdowns
if (subSelect) subSelect.disabled = true;
if (specificTypeSelect) specificTypeSelect.disabled = true;
if (typeSelect) typeSelect.disabled = true;
// Show reset notification
const notification = document.createElement('div');
notification.className = 'alert alert-info alert-dismissible fade show mt-3';
notification.innerHTML = `
<i class="fas fa-info-circle me-2"></i>
Form has been reset. You can now create a new quotation.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
form.insertBefore(notification, form.firstChild);
console.log('Form reset complete');
}
// Handle form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
console.log('Form submitted');
// Get submit button robustly
let submitButton = document.getElementById('calculateBtn') || form.querySelector('button[type="submit"]');
if (!submitButton) {
console.error('Submit button not found during form submission!');
alert('Error: Submit button not found. Please refresh the page and try again.');
return;
}
// Check if submission is already in progress
if (submitButton.disabled || form.dataset.submitting === 'true') {
console.log('Form submission already in progress or cooldown active, ignoring duplicate click');
return;
}
// Set submission state
form.dataset.submitting = 'true';
// Disable submit button and show loading state
submitButton.disabled = true;
const originalButtonText = submitButton.innerHTML;
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Calculating...';
submitButton.classList.add('btn-secondary');
submitButton.classList.remove('btn-primary');
submitButton.title = 'Please wait while calculating...';
try {
// Log all form fields
console.log('Form fields:');
for (let [key, value] of new FormData(form).entries()) {
console.log(`${key}: ${value}`);
}
// Validate required fields
const mainType = mainSelect.value;
const subType = subSelect.value;
const specificType = specificTypeSelect.value;
const stage = stageSelect.value;
const type = typeSelect.value;
const toolingId = toolingIdInput ? toolingIdInput.value : '';
console.log('Required fields:', {
mainType,
subType,
specificType,
stage,
type,
toolingId
});
if (!mainType) {
alert('Please select a main type');
return;
}
if (!subType) {
alert('Please select a sub type');
return;
}
if (!stage) {
alert('Please select a stage');
return;
}
if (!type) {
alert('Please select a type');
return;
}
// Validate tooling ID
if (!toolingId) {
alert('Please enter a Tooling ID');
return;
}
if (!/^\d{5}$/.test(toolingId)) {
alert('Tooling ID must be exactly 5 digits');
return;
}
// Check if specific type is required
const validSpecificTypes = specificTypeOptions[subType] || [];
if (validSpecificTypes.length > 0 && !specificType) {
alert('Please select a specific type');
return;
}
// Create form data
const formData = new FormData(form);
// Add CSRF token to form data
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
formData.append('csrf_token', csrfToken);
// Remove specific type if it's not needed
if (validSpecificTypes.length === 0) {
formData.delete('mold_specific_type');
}
// Log the final form data being sent
console.log('Final form data being sent:');
for (let [key, value] of formData.entries()) {
console.log(`${key}: ${value}`);
}
const response = await fetch('/calculate', {
method: 'POST',
body: formData
});
console.log('Response status:', response.status);
const responseText = await response.text();
console.log('Response text:', responseText);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}, body: ${responseText}`);
}
const data = JSON.parse(responseText);
console.log('Calculation result:', data);
// Get the result element
const resultElement = document.getElementById('result');
// Update the result display
updateResultsDisplay(data);
// Save the quotation to the database
try {
console.log('Saving quotation to database...');
const saveResponse = await fetch('/save-quotation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify(data)
});
const saveResult = await saveResponse.json();
console.log('Save result:', saveResult);
if (saveResult.success) {
console.log('Quotation saved successfully with ID:', saveResult.quotation_id);
// Show success message
const successMessage = document.createElement('div');
successMessage.className = 'alert alert-success alert-dismissible fade show';
successMessage.innerHTML = `
<i class="fas fa-check-circle me-2"></i>
Quotation saved successfully! ID: ${saveResult.quotation_id}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
resultElement.insertBefore(successMessage, resultElement.firstChild);
// Reset form after successful save
resetForm(form);
// Refresh quote history
setTimeout(() => {
refreshQuoteHistory();
}, 1000);
} else if (saveResult.duplicate) {
console.log('Duplicate submission detected:', saveResult.message);
const duplicateMessage = document.createElement('div');
duplicateMessage.className = 'alert alert-warning alert-dismissible fade show';
duplicateMessage.innerHTML = `
<i class="fas fa-exclamation-triangle me-2"></i>
${saveResult.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
resultElement.insertBefore(duplicateMessage, resultElement.firstChild);
// Reset form even on duplicate to prevent repeated attempts
resetForm(form);
}
} catch (saveError) {
console.error('Error saving quotation:', saveError);
const errorMessage = document.createElement('div');
errorMessage.className = 'alert alert-danger alert-dismissible fade show';
errorMessage.innerHTML = `
<i class="fas fa-exclamation-circle me-2"></i>
Error saving quotation. Please try again later.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
resultElement.insertBefore(errorMessage, resultElement.firstChild);
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred while calculating. Please check the console for details.');
} finally {
// Re-enable submit button and restore original state after a delay
setTimeout(() => {
let submitButton = document.getElementById('calculateBtn') || form.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = false;
submitButton.innerHTML = originalButtonText;
submitButton.classList.remove('btn-secondary');
submitButton.classList.add('btn-primary');
submitButton.title = 'Calculate mold cost';
}
// Reset submission state
form.dataset.submitting = 'false';
}, 2000); // 2 second cooldown before allowing new submission
}
});
}
// Function to update results display
function updateResultsDisplay(data) {
const resultElement = document.getElementById('result');
if (!resultElement) {
console.error('Result element not found');
return;
}
// Format the results to match the provided screenshot
const formattedResults = `
<div class="card bg-white shadow-sm p-4 mb-4" style="border-radius: 1rem; border: 1px solid #e3e6ea;">
<div class="mb-4 pb-2 border-bottom d-flex align-items-center" style="font-size:1.4rem;font-weight:600;color:#1976d2;">
<i class="fas fa-clipboard-list me-2"></i>Calculation Results
</div>
<!-- Factory Information -->
<div class="mb-4">
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
<i class="fas fa-industry me-2"></i>Factory Information
</div>
<div class="row g-3">
<div class="col-md-4">
<div class="card p-3 h-100 bg-light border-0">
<div class="fw-bold">Mold Shop</div>
<div>${data.mold_shop_name || '-'}</div>
<div class="text-muted small">${data.mold_shop_country || '-'}</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 h-100 bg-light border-0">
<div class="fw-bold">T1 Factory</div>
<div>${data.t1_shoe_factory_name || '-'}</div>
<div class="text-muted small">${data.t1_shoe_factory_country || '-'}</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 h-100 bg-light border-0">
<div class="fw-bold">T2 Factory</div>
<div>${data.t2_component_factory_name || '-'}</div>
<div class="text-muted small">${data.t2_component_factory_country || '-'}</div>
</div>
</div>
</div>
</div>
<!-- Mold Information -->
<div class="mb-4">
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
<i class="fas fa-cube me-2"></i>Mold Information
</div>
<div class="row g-3">
<div class="col-md-4">
<div class="card p-3 h-100">
<div class="fw-bold">Model</div>
<div>${data.model_name || '-'}</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 h-100">
<div class="fw-bold">Tooling</div>
<div>${data.tooling_id || '-'}</div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 h-100">
<div class="fw-bold">Season & Stage</div>
<div>${(data.season || '-') + '/' + (data.stage || '-')}</div>
</div>
</div>
</div>
</div>
<!-- Mold Specifications -->
<div class="mb-4">
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
<i class="fas fa-wrench me-2"></i>Mold Specifications
</div>
<div class="row g-3">
<div class="col-md-6">
<div class="card p-3 h-100">
<div class="fw-bold">Mold Type</div>
<div>${data.mold_main_type || '-'}</div>
<div class="text-muted small">${data.mold_sub_type || ''}</div>
</div>
</div>
<div class="col-md-6">
<div class="card p-3 h-100">
<div class="fw-bold mb-2">Complexity Factors</div>
<div class="row">
<div class="col-6 small">High Sidewall</div>
<div class="col-6 small text-end">${data.complexity_high_sidewall === 'yes' ? '<b>Yes</b>' : '-'}</div>
<div class="col-6 small">Sliders</div>
<div class="col-6 small text-end">${data.sliders_count ? `<b>${data.sliders_count} slider${data.sliders_count > 1 ? 's' : ''}</b>` : '-'}</div>
<div class="col-6 small">Cavities</div>
<div class="col-6 small text-end">${data.cavity_count ? `<b>${data.cavity_count} pair</b>` : '-'}</div>
<div class="col-6 small">Digital Texture</div>
<div class="col-6 small text-end">${data.digital_texture_type ? `<b>${data.digital_texture_type}</b>` : '-'}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Cost Breakdown & Approvals -->
<div class="row g-3">
<div class="col-md-8">
<div class="mb-4">
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
<i class="fas fa-table me-2"></i>Cost Breakdown
</div>
<div class="card p-0">
<table class="table mb-0">
<tbody>
<tr><td>Base Price</td><td class="text-end">$${Number(data.base_price).toFixed(2)}</td></tr>
<tr><td>Complexity Adjustment</td><td class="text-end">$${Number(data.complexity_cost).toFixed(2)}</td></tr>
<tr><td>Digital Texture Cost</td><td class="text-end">$${Number(data.texture_cost).toFixed(2)}</td></tr>
<tr class="table-primary fw-bold"><td>Total Cost</td><td class="text-end">$${Number(data.total_cost).toFixed(2)}</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-4">
<div class="mb-4">
<div class="d-flex align-items-center mb-2" style="color:#1976d2;font-weight:600;font-size:1.1rem;">
<i class="fas fa-check-circle me-2"></i>Approvals
</div>
<div class="card p-3">
<div class="mb-3 text-muted small"><i class="far fa-calendar-alt me-1"></i>Issue Date: <b>${data.issue_date || '-'}</b></div>
<div class="mb-2">Mold Shop<br><b>${data.approved_by_mold_shop || '-'}</b></div>
<div class="mb-2">T1 Tooling<br><b>${data.approved_by_t1_tooling || '-'}</b></div>
<div>LO Tooling<br><b>${data.approved_by_lo_tooling || '-'}</b></div>
</div>
</div>
</div>
</div>
</div>
`;
resultElement.innerHTML = formattedResults;
}
// Helper function to format currency
function formatCurrency(value) {
if (value === null || value === undefined) {
return 'N/A';
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
}
// Refresh quote history
async function refreshQuoteHistory() {
try {
const response = await fetch('/quote-history');
if (response.ok) {
const data = await response.json();
// Update quote history display
const historyContainer = document.getElementById('quote-history');
if (historyContainer) {
historyContainer.innerHTML = data.html;
}
}
} catch (error) {
console.error('Error refreshing quote history:', error);
}
}
+15
View File
@@ -0,0 +1,15 @@
<div class="list-group">
{% for quote in quote_history %}
<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<span class="badge bg-primary me-2">#{{ quote.id }}</span>
{{ quote.created_at.strftime('%Y-%m-%d %H:%M') }}
</div>
<div class="fw-bold">¥{{ "{:,.0f}".format(quote.total_cost) }}</div>
</div>
{% else %}
<div class="list-group-item text-muted">
No calculations yet
</div>
{% endfor %}
</div>
+298
View File
@@ -0,0 +1,298 @@
{% macro render_quotation_details(quotation, show_actions=true) %}
<div class="quotation-details">
<div class="detail-card">
<h3>Basic Information</h3>
<div class="detail-grid">
<div class="detail-item">
<label>ID</label>
<span>{{ quotation.id }}</span>
</div>
<div class="detail-item">
<label>User</label>
<span>{{ quotation.user.username }}</span>
</div>
<div class="detail-item">
<label>Created</label>
<span>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
<div class="detail-item">
<label>Status</label>
<span class="badge badge-success">Completed</span>
</div>
</div>
</div>
<div class="detail-card">
<h3>Project Details</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Model Name</label>
<span>{{ quotation.model_name }}</span>
</div>
<div class="detail-item">
<label>Tooling ID</label>
<span>{{ quotation.tooling_id }}</span>
</div>
<div class="detail-item">
<label>Season</label>
<span>{{ quotation.season }}</span>
</div>
<div class="detail-item">
<label>Stage</label>
<span>{{ quotation.stage }}</span>
</div>
<div class="detail-item">
<label>Country</label>
<span>{{ quotation.country }}</span>
</div>
<div class="detail-item">
<label>Issue Date</label>
<span>{{ quotation.issue_date.strftime('%Y-%m-%d') }}</span>
</div>
</div>
</div>
<div class="detail-card">
<h3>Mold Specifications</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Mold Main Type</label>
<span>{{ quotation.mold_main_type }}</span>
</div>
<div class="detail-item">
<label>Mold Sub Type</label>
<span>{{ quotation.mold_sub_type }}</span>
</div>
<div class="detail-item">
<label>High Sidewall Complexity</label>
<span>{{ 'Yes' if quotation.complexity_high_sidewall else 'No' }}</span>
</div>
<div class="detail-item">
<label>Number of Sliders</label>
<span>{{ quotation.sliders_count }}</span>
</div>
<div class="detail-item">
<label>Number of Cavities</label>
<span>{{ quotation.cavity_count }}</span>
</div>
<div class="detail-item">
<label>Digital Texture Type</label>
<span>{{ quotation.digital_texture_type }}</span>
</div>
</div>
</div>
<div class="detail-card">
<h3>Cost Breakdown</h3>
<div class="cost-breakdown">
<div class="cost-item">
<label>Net Base Price</label>
<span>${{ "%.2f"|format(quotation.net_base_price) }}</span>
</div>
<div class="cost-item">
<label>Tax Rate</label>
<span>{{ "%.1f"|format(quotation.tax_rate * 100) }}%</span>
</div>
<div class="cost-item total">
<label>Total Cost</label>
<span>${{ "%.2f"|format(quotation.total_cost) }}</span>
</div>
</div>
</div>
<div class="detail-card">
<h3>Factory Information</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Mold Shop Name</label>
<span>{{ quotation.mold_shop_name }}</span>
</div>
<div class="detail-item">
<label>T1 Factory Name</label>
<span>{{ quotation.t1_factory_name or 'N/A' }}</span>
</div>
<div class="detail-item">
<label>T2 Component Factory</label>
<span>{{ quotation.t2_component_factory or 'N/A' }}</span>
</div>
</div>
</div>
{% if show_actions %}
<div class="detail-actions">
<a href="{{ url_for('admin.quotations') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Quotations
</a>
<button class="btn btn-primary" onclick="window.print()">
<i class="fas fa-print"></i> Print Quotation
</button>
</div>
{% endif %}
</div>
<style>
.quotation-details {
display: flex;
flex-direction: column;
gap: 30px;
}
.detail-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.detail-card h3 {
margin: 0 0 20px;
color: #2c3e50;
font-size: 1.2rem;
font-weight: 600;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.detail-item label {
color: #6c757d;
font-size: 0.9rem;
}
.detail-item span {
font-size: 1.1rem;
color: #2c3e50;
}
.cost-breakdown {
display: flex;
flex-direction: column;
gap: 15px;
}
.cost-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #dee2e6;
}
.cost-item:last-child {
border-bottom: none;
}
.cost-item label {
color: #6c757d;
font-size: 1rem;
}
.cost-item span {
font-size: 1.1rem;
color: #2c3e50;
font-weight: 500;
}
.cost-item.total {
margin-top: 10px;
padding-top: 20px;
border-top: 2px solid #dee2e6;
}
.cost-item.total label {
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.cost-item.total span {
font-size: 1.4rem;
font-weight: 700;
color: #28a745;
}
.detail-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 5px;
text-decoration: none;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-primary:hover {
background-color: #0056b3;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.badge {
padding: 5px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-success {
background-color: #28a745;
color: white;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
.detail-actions {
flex-direction: column;
}
.btn {
width: 100%;
justify-content: center;
}
}
@media print {
.detail-actions {
display: none;
}
.detail-card {
break-inside: avoid;
box-shadow: none;
border: 1px solid #dee2e6;
}
}
</style>
{% endmacro %}
+130
View File
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Panel{% endblock %}</title>
{% block styles %}
<!-- Bootstrap CSS -->
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.css') }}">
<!-- Admin Styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
<!-- Custom Styles -->
<style>
:root {
--primary-color: #0d6efd;
--secondary-color: #6c757d;
--success-color: #198754;
--warning-color: #ffc107;
--danger-color: #dc3545;
--light-color: #f8f9fa;
--dark-color: #212529;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f8f9fa;
}
.navbar {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.card {
border: none;
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075);
transition: box-shadow 0.15s ease-in-out;
}
.card:hover {
box-shadow: 0 0.5rem 1rem rgba(0,0,0,0.15);
}
.btn {
border-radius: 0.375rem;
}
.table {
border-radius: 0.375rem;
overflow: hidden;
}
.alert {
border: none;
border-radius: 0.375rem;
}
</style>
{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('admin.dashboard') }}">
<i class="bi bi-gear-fill me-2"></i>Admin Panel
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.dashboard' %}active{% endif %}"
href="{{ url_for('admin.dashboard') }}">
<i class="bi bi-graph-up me-1"></i>Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.users' %}active{% endif %}"
href="{{ url_for('admin.users') }}">
<i class="bi bi-people me-1"></i>Users
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.quotations' %}active{% endif %}"
href="{{ url_for('admin.quotations') }}">
<i class="bi bi-file-earmark-text me-1"></i>Quotations
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'admin.cost_factors' %}active{% endif %}"
href="{{ url_for('admin.cost_factors') }}">
<i class="bi bi-calculator me-1"></i>Cost Factors
</a>
</li>
</ul>
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid py-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
{% block scripts %}
<!-- Bootstrap Bundle with Popper -->
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<!-- jQuery -->
<script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
{% endblock %}
</body>
</html>
@@ -0,0 +1,198 @@
{% extends "admin/base.html" %}
{% block admin_title %}Cost Factor History{% endblock %}
{% block admin_header %}Change History: {{ factor.name }}{% endblock %}
{% block admin_actions %}
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Cost Factors
</a>
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-primary">
<i class="fas fa-edit"></i> Edit Factor
</a>
{% endblock %}
{% block admin_content %}
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Factor Information</h5>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-sm-4">Name</dt>
<dd class="col-sm-8">{{ factor.name }}</dd>
<dt class="col-sm-4">Category</dt>
<dd class="col-sm-8">
<span class="badge bg-primary">{{ factor.category }}</span>
</dd>
<dt class="col-sm-4">Current Value</dt>
<dd class="col-sm-8">
<span class="badge bg-success">{{ "%.4f"|format(factor.factor_value) }}</span>
</dd>
<dt class="col-sm-4">Status</dt>
<dd class="col-sm-8">
{% if factor.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-danger">Inactive</span>
{% endif %}
</dd>
<dt class="col-sm-4">Created</dt>
<dd class="col-sm-8">{{ factor.created_at.strftime('%Y-%m-%d %H:%M') if factor.created_at else 'Unknown' }}</dd>
<dt class="col-sm-4">Last Updated</dt>
<dd class="col-sm-8">{{ factor.updated_at.strftime('%Y-%m-%d %H:%M') if factor.updated_at else 'Never' }}</dd>
</dl>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Change History</h5>
</div>
<div class="card-body">
{% if history %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date & Time</th>
<th>Changed By</th>
<th>Old Value</th>
<th>New Value</th>
<th>Change</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% for change in history %}
<tr>
<td>{{ change.changed_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
{% if change.user %}
<span class="badge bg-info">{{ change.user.username }}</span>
{% else %}
<span class="text-muted">Unknown</span>
{% endif %}
</td>
<td>
{% if change.old_value is not none %}
<span class="badge bg-secondary">{{ "%.4f"|format(change.old_value) }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
<td>
<span class="badge bg-success">{{ "%.4f"|format(change.new_value) }}</span>
</td>
<td>
{% if change.old_value is not none %}
{% set diff = change.new_value - change.old_value %}
{% if diff > 0 %}
<span class="badge bg-danger">+{{ "%.4f"|format(diff) }}</span>
{% elif diff < 0 %}
<span class="badge bg-warning">{{ "%.4f"|format(diff) }}</span>
{% else %}
<span class="badge bg-secondary">No change</span>
{% endif %}
{% else %}
<span class="badge bg-info">Initial</span>
{% endif %}
</td>
<td>
{% if change.change_reason %}
<small class="text-muted">{{ change.change_reason }}</small>
{% else %}
<span class="text-muted">No reason provided</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-history fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Changes Yet</h5>
<p class="text-muted">This factor hasn't been modified since it was created.</p>
</div>
{% endif %}
</div>
</div>
{% if history %}
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">Summary</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<h4 class="text-primary">{{ history|length }}</h4>
<small class="text-muted">Total Changes</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h4 class="text-success">{{ history|selectattr('change_reason')|list|length }}</h4>
<small class="text-muted">With Reasons</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h4 class="text-info">{{ history|selectattr('user')|list|length }}</h4>
<small class="text-muted">By Known Users</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<h4 class="text-warning">{{ (history|last).changed_at.strftime('%Y-%m-%d') if history else 'N/A' }}</h4>
<small class="text-muted">Last Modified</small>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block styles %}
{{ super() }}
<style>
.badge {
font-size: 0.875em;
}
dl.row dt {
font-weight: 600;
color: #6c757d;
}
dl.row dd {
margin-bottom: 0.5rem;
}
.table th {
background-color: #f8f9fa;
border-top: none;
font-weight: 600;
}
.text-center h4 {
margin-bottom: 0.25rem;
}
</style>
{% endblock %}
+558
View File
@@ -0,0 +1,558 @@
{% extends "admin/base.html" %}
{% block title %}Cost Factors Management{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">
<i class="bi bi-gear-fill me-2 text-primary"></i>Cost Factors Management
</h1>
<p class="text-muted mb-0">Configure calculation parameters and factors</p>
</div>
<div class="d-flex gap-2">
<button onclick="resetFactors()" class="btn btn-warning">
<i class="bi bi-arrow-clockwise me-1"></i>Reset to Defaults
</button>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Dashboard
</a>
</div>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle me-2"></i>{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Calculation Logic Diagram -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-light border-0">
<h3 class="card-title mb-0">
<i class="bi bi-diagram-3 me-2 text-dark"></i>Calculation Logic
</h3>
</div>
<div class="card-body">
<div class="mermaid">
graph TD
A[Base Cost] --> B[Base Factors]
B --> C[Mold Factors]
C --> D[Complexity Factors]
D --> E[Texture Factors]
E --> F[Tax Factors]
F --> G[Final Cost]
B --> B1[Base Factor 1]
B --> B2[Base Factor 2]
C --> C1[Mold Factor 1]
C --> C2[Mold Factor 2]
D --> D1[Complexity Factor 1]
D --> D2[Complexity Factor 2]
E --> E1[Texture Factor 1]
E --> E2[Texture Factor 2]
F --> F1[Tax Rate]
</div>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
<div class="card statistics-card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="icon-wrapper bg-primary bg-opacity-10 mx-auto mb-3">
<i class="bi bi-calculator text-primary fs-1"></i>
</div>
<h4 class="mb-1">{{ base_factors|length if base_factors else 0 }}</h4>
<p class="text-muted mb-0 small">Base Factors</p>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
<div class="card statistics-card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="icon-wrapper bg-success bg-opacity-10 mx-auto mb-3">
<i class="bi bi-building text-success fs-1"></i>
</div>
<h4 class="mb-1">{{ mold_factors|length if mold_factors else 0 }}</h4>
<p class="text-muted mb-0 small">Mold Factors</p>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
<div class="card statistics-card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="icon-wrapper bg-warning bg-opacity-10 mx-auto mb-3">
<i class="bi bi-puzzle text-warning fs-1"></i>
</div>
<h4 class="mb-1">{{ complexity_factors|length if complexity_factors else 0 }}</h4>
<p class="text-muted mb-0 small">Complexity</p>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
<div class="card statistics-card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="icon-wrapper bg-info bg-opacity-10 mx-auto mb-3">
<i class="bi bi-palette text-info fs-1"></i>
</div>
<h4 class="mb-1">{{ texture_factors|length if texture_factors else 0 }}</h4>
<p class="text-muted mb-0 small">Texture</p>
</div>
</div>
</div>
<div class="col-xl-2 col-md-4 col-sm-6 mb-3">
<div class="card statistics-card border-0 shadow-sm h-100">
<div class="card-body text-center">
<div class="icon-wrapper bg-danger bg-opacity-10 mx-auto mb-3">
<i class="bi bi-percent text-danger fs-1"></i>
</div>
<h4 class="mb-1">{{ tax_factors|length if tax_factors else 0 }}</h4>
<p class="text-muted mb-0 small">Tax Factors</p>
</div>
</div>
</div>
</div>
<!-- Cost Factors Sections -->
<div class="row">
<div class="col-12">
<!-- Base Factors -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-primary bg-opacity-10 border-0">
<h3 class="card-title mb-0">
<i class="bi bi-layers me-2 text-primary"></i>Base Factors
<span class="badge bg-primary ms-2">{{ base_factors|length if base_factors else 0 }}</span>
</h3>
</div>
<div class="card-body">
{% if base_factors %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0">Factor Name</th>
<th class="border-0">Value</th>
<th class="border-0">Description</th>
<th class="border-0 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for factor in base_factors %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="bi bi-calculator text-primary"></i>
</div>
<div>
<div class="fw-medium">{{ factor.name }}</div>
<small class="text-muted">{{ factor.category }}</small>
</div>
</div>
</td>
<td>
<span class="badge bg-primary fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
</td>
<td>{{ factor.description or 'Base calculation factor' }}</td>
<td class="text-end">
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-clock-history me-1"></i>History
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
<p class="text-muted mb-0">No base factors found.</p>
</div>
{% endif %}
</div>
</div>
<!-- Mold Factors -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-success bg-opacity-10 border-0">
<h3 class="card-title mb-0">
<i class="bi bi-building me-2 text-success"></i>Mold Factors
<span class="badge bg-success ms-2">{{ mold_factors|length if mold_factors else 0 }}</span>
</h3>
</div>
<div class="card-body">
{% if mold_factors %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0">Factor Name</th>
<th class="border-0">Value</th>
<th class="border-0">Description</th>
<th class="border-0 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for factor in mold_factors %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-success bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="bi bi-box text-success"></i>
</div>
<div>
<div class="fw-medium">{{ factor.name }}</div>
<small class="text-muted">{{ factor.category }}</small>
</div>
</div>
</td>
<td>
<span class="badge bg-success fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
</td>
<td>{{ factor.description or 'Mold-related factor' }}</td>
<td class="text-end">
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-clock-history me-1"></i>History
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
<p class="text-muted mb-0">No mold factors found.</p>
</div>
{% endif %}
</div>
</div>
<!-- Complexity Factors -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-warning bg-opacity-10 border-0">
<h3 class="card-title mb-0">
<i class="bi bi-puzzle me-2 text-warning"></i>Complexity Factors
<span class="badge bg-warning ms-2">{{ complexity_factors|length if complexity_factors else 0 }}</span>
</h3>
</div>
<div class="card-body">
{% if complexity_factors %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0">Factor Name</th>
<th class="border-0">Value</th>
<th class="border-0">Description</th>
<th class="border-0 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for factor in complexity_factors %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-warning bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="bi bi-gear text-warning"></i>
</div>
<div>
<div class="fw-medium">{{ factor.name }}</div>
<small class="text-muted">{{ factor.category }}</small>
</div>
</div>
</td>
<td>
<span class="badge bg-warning fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
</td>
<td>{{ factor.description or 'Complexity-related factor' }}</td>
<td class="text-end">
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-warning">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-clock-history me-1"></i>History
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
<p class="text-muted mb-0">No complexity factors found.</p>
</div>
{% endif %}
</div>
</div>
<!-- Texture Factors -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-info bg-opacity-10 border-0">
<h3 class="card-title mb-0">
<i class="bi bi-brush me-2 text-info"></i>Texture Factors
<span class="badge bg-info ms-2">{{ texture_factors|length if texture_factors else 0 }}</span>
</h3>
</div>
<div class="card-body">
{% if texture_factors %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0">Factor Name</th>
<th class="border-0">Value</th>
<th class="border-0">Description</th>
<th class="border-0 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for factor in texture_factors %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="bi bi-palette text-info"></i>
</div>
<div>
<div class="fw-medium">{{ factor.name }}</div>
<small class="text-muted">{{ factor.category }}</small>
</div>
</div>
</td>
<td>
<span class="badge bg-info fs-6">{{ "%.2f"|format(factor.factor_value) }}</span>
</td>
<td>{{ factor.description or 'Texture-related factor' }}</td>
<td class="text-end">
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-clock-history me-1"></i>History
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
<p class="text-muted mb-0">No texture factors found.</p>
</div>
{% endif %}
</div>
</div>
<!-- Tax Factors -->
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-danger bg-opacity-10 border-0">
<h3 class="card-title mb-0">
<i class="bi bi-percent me-2 text-danger"></i>Tax Factors
<span class="badge bg-danger ms-2">{{ tax_factors|length if tax_factors else 0 }}</span>
</h3>
</div>
<div class="card-body">
{% if tax_factors %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0">Country/Region</th>
<th class="border-0">Tax Rate</th>
<th class="border-0 text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for factor in tax_factors %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-danger bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 40px; height: 40px;">
<i class="bi bi-flag text-danger"></i>
</div>
<div>
<div class="fw-medium">{{ factor.name }}</div>
<small class="text-muted">{{ factor.description or 'Tax rate' }}</small>
</div>
</div>
</td>
<td>
<span class="badge bg-danger fs-6">{{ "%.1f"|format(factor.factor_value * 100) }}%</span>
</td>
<td class="text-end">
<a href="{{ url_for('admin.edit_cost_factor', factor_id=factor.id) }}" class="btn btn-sm btn-outline-danger">
<i class="bi bi-pencil me-1"></i>Edit
</a>
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}" class="btn btn-sm btn-outline-info">
<i class="bi bi-clock-history me-1"></i>History
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle fs-1 text-warning mb-3"></i>
<p class="text-muted mb-0">No tax factors found.</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Add Mermaid.js for diagram rendering -->
<script src="{{ url_for('static', filename='js/mermaid.min.js') }}"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'default',
securityLevel: 'loose',
fontFamily: '"Helvetica Neue", Arial, sans-serif'
});
document.addEventListener('DOMContentLoaded', function() {
console.log('Cost Factors Template Loaded');
try {
const debugInfo = {
baseFactors: {{ base_factors|length if base_factors else 0 }},
moldFactors: {{ mold_factors|length if mold_factors else 0 }},
complexityFactors: {{ complexity_factors|length if complexity_factors else 0 }},
textureFactors: {{ texture_factors|length if texture_factors else 0 }},
taxFactors: {{ tax_factors|length if tax_factors else 0 }}
};
console.log('Debug Info:', debugInfo);
} catch (error) {
console.error('Error in debug logging:', error);
}
});
function resetFactors() {
if (confirm('Are you sure you want to reset all cost factors to their default values? This action cannot be undone.')) {
fetch('{{ url_for("admin.reset_cost_factors") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
}).then(response => {
if (response.ok) {
window.location.reload();
} else {
alert('Error resetting factors. Please try again.');
}
}).catch(error => {
console.error('Error:', error);
alert('Error resetting factors. Please try again.');
});
}
}
</script>
{% block styles %}
{{ super() }}
<style>
/* Card Styles */
.card {
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
/* Table Styles */
.table th {
font-weight: 600;
color: #495057;
}
.table td {
vertical-align: middle;
}
/* Badge Styles */
.badge {
padding: 0.5em 0.8em;
}
.badge.fs-6 {
font-size: 0.875rem !important;
}
/* Icon Styles */
.avatar-sm i {
font-size: 1.25rem;
}
/* Animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.card {
animation: fadeIn 0.3s ease-out;
}
/* Statistics Cards */
.statistics-card {
border-radius: 1rem;
overflow: hidden;
}
.statistics-card .icon-wrapper {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
/* Mermaid Diagram */
.mermaid {
background: white;
padding: 1rem;
border-radius: 0.5rem;
}
/* Responsive Design */
@media (max-width: 768px) {
.statistics-card {
margin-bottom: 1rem;
}
.table-responsive {
margin: 0 -1rem;
}
}
</style>
{% endblock %}
{% endblock %}
+245
View File
@@ -0,0 +1,245 @@
{% extends "admin/base.html" %}
{% block title %}Admin Dashboard{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">
<i class="bi bi-speedometer2 me-2 text-primary"></i>Admin Dashboard
</h1>
<p class="text-muted mb-0">Overview of system statistics and recent activity</p>
</div>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle me-2"></i>{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted mb-1">Total Users</h6>
<h3 class="mb-0">{{ total_users }}</h3>
</div>
<div class="bg-primary bg-opacity-10 rounded-circle p-3">
<i class="bi bi-people text-primary fs-1"></i>
</div>
</div>
<div class="mt-3">
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>{{ active_users }} active
</span>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted mb-1">Total Quotations</h6>
<h3 class="mb-0">{{ total_quotations }}</h3>
</div>
<div class="bg-success bg-opacity-10 rounded-circle p-3">
<i class="bi bi-file-earmark-text text-success fs-1"></i>
</div>
</div>
<div class="mt-3">
<span class="badge bg-info">
<i class="bi bi-graph-up me-1"></i>All time
</span>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted mb-1">Active Regions</h6>
<h3 class="mb-0">{{ active_regions }}</h3>
</div>
<div class="bg-warning bg-opacity-10 rounded-circle p-3">
<i class="bi bi-globe text-warning fs-1"></i>
</div>
</div>
<div class="mt-3">
<span class="badge bg-warning">
<i class="bi bi-flag me-1"></i>Active regions
</span>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-3">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="text-muted mb-1">Recent Quotes</h6>
<h3 class="mb-0">{{ recent_quotations|length }}</h3>
</div>
<div class="bg-info bg-opacity-10 rounded-circle p-3">
<i class="bi bi-clock text-info fs-1"></i>
</div>
</div>
<div class="mt-3">
<span class="badge bg-info">
<i class="bi bi-calendar me-1"></i>Last 5 quotes
</span>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<!-- Recent Activity -->
<div class="col-lg-8 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light border-0">
<h5 class="card-title mb-0">
<i class="bi bi-graph-up me-2 text-primary"></i>Recent Activity
</h5>
</div>
<div class="card-body">
{% if recent_quotations %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Project</th>
<th>Amount</th>
<th>Date</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for quotation in recent_quotations %}
<tr>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-3" style="width: 32px; height: 32px;">
<i class="bi bi-person text-primary"></i>
</div>
<div>
<div class="fw-medium">{{ quotation.user.username }}</div>
<small class="text-muted">{{ quotation.user.email }}</small>
</div>
</div>
</td>
<td>{{ quotation.project_name }}</td>
<td>
<span class="badge bg-success">${{ "%.2f"|format(quotation.total_cost) }}</span>
</td>
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<span class="badge bg-primary">Completed</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="text-center mt-3">
<a href="{{ url_for('admin.quotations') }}" class="btn btn-outline-primary">
<i class="bi bi-eye me-1"></i>View All Quotations
</a>
</div>
{% else %}
<div class="text-center py-4">
<i class="bi bi-file-earmark-text fs-1 text-muted mb-3"></i>
<p class="text-muted mb-0">No recent quotations found.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="col-lg-4 mb-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light border-0">
<h5 class="card-title mb-0">
<i class="bi bi-lightning me-2 text-warning"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.users') }}" class="btn btn-outline-primary">
<i class="bi bi-people me-2"></i>Manage Users
</a>
<a href="{{ url_for('admin.quotations') }}" class="btn btn-outline-success">
<i class="bi bi-file-earmark-text me-2"></i>View Quotations
</a>
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-warning">
<i class="bi bi-gear me-2"></i>Cost Factors
</a>
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-info">
<i class="bi bi-calculator me-2"></i>Calculator Settings
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.card {
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.avatar-sm {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.table th {
font-weight: 600;
color: #495057;
}
.table td {
vertical-align: middle;
}
.badge {
font-size: 0.875em;
}
.btn {
border-radius: 0.375rem;
}
</style>
{% endblock %}
@@ -0,0 +1,238 @@
{% extends "admin/base.html" %}
{% block title %}Edit Cost Factor: {{ factor.name }}{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-0">
<i class="bi bi-pencil-square me-2 text-primary"></i>Edit Cost Factor
</h1>
<p class="text-muted mb-0">{{ factor.name }} ({{ factor.category }})</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Back to Cost Factors
</a>
</div>
</div>
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle me-2"></i>{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="row">
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light border-0">
<h5 class="card-title mb-0">
<i class="bi bi-gear me-2"></i>Edit Factor Details
</h5>
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="category" class="form-label fw-semibold">Category</label>
<input type="text" class="form-control bg-light" id="category" value="{{ factor.category }}" readonly>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="name" class="form-label fw-semibold">Name</label>
<input type="text" class="form-control bg-light" id="name" value="{{ factor.name }}" readonly>
</div>
</div>
</div>
<div class="mb-3">
<label for="factor_value" class="form-label fw-semibold">
Factor Value <span class="text-danger">*</span>
</label>
<input type="number" class="form-control form-control-lg" id="factor_value" name="factor_value"
value="{{ "%.4f"|format(factor.factor_value) }}" step="0.0001" min="0" required>
<div class="form-text">
{% if factor.category == 'tax' %}
<i class="bi bi-info-circle me-1"></i>Enter as decimal (e.g., 0.06 for 6%)
{% elif factor.category == 'base' %}
<i class="bi bi-info-circle me-1"></i>Enter the base price value
{% else %}
<i class="bi bi-info-circle me-1"></i>Enter the multiplier factor
{% endif %}
</div>
</div>
{% if factor.calculation_formula %}
<div class="mb-3">
<label for="calculation_formula" class="form-label fw-semibold">Calculation Formula</label>
<input type="text" class="form-control bg-light" id="calculation_formula"
value="{{ factor.calculation_formula }}" readonly>
</div>
{% endif %}
{% if factor.percentage %}
<div class="mb-3">
<label for="percentage" class="form-label fw-semibold">Percentage</label>
<input type="text" class="form-control bg-light" id="percentage"
value="{{ factor.percentage }}" readonly>
</div>
{% endif %}
<div class="mb-3">
<label for="description" class="form-label fw-semibold">Description</label>
<textarea class="form-control bg-light" id="description" rows="3" readonly>{{ factor.description }}</textarea>
</div>
<div class="mb-4">
<label for="change_reason" class="form-label fw-semibold">
<i class="bi bi-chat-text me-1"></i>Reason for Change
</label>
<textarea class="form-control" id="change_reason" name="change_reason" rows="3"
placeholder="Please provide a reason for this change (optional but recommended)"></textarea>
<div class="form-text">
<i class="bi bi-clock-history me-1"></i>This will be recorded in the change history for audit purposes.
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('admin.cost_factors') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light border-0">
<h5 class="card-title mb-0">
<i class="bi bi-info-circle me-2"></i>Current Information
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 fw-semibold">Category</dt>
<dd class="col-sm-8">
<span class="badge bg-primary">{{ factor.category }}</span>
</dd>
<dt class="col-sm-4 fw-semibold">Name</dt>
<dd class="col-sm-8">{{ factor.name }}</dd>
<dt class="col-sm-4 fw-semibold">Current Value</dt>
<dd class="col-sm-8">
<span class="badge bg-success fs-6">{{ "%.4f"|format(factor.factor_value) }}</span>
</dd>
<dt class="col-sm-4 fw-semibold">Status</dt>
<dd class="col-sm-8">
{% if factor.is_active %}
<span class="badge bg-success">
<i class="bi bi-check-circle me-1"></i>Active
</span>
{% else %}
<span class="badge bg-danger">
<i class="bi bi-x-circle me-1"></i>Inactive
</span>
{% endif %}
</dd>
<dt class="col-sm-4 fw-semibold">Created</dt>
<dd class="col-sm-8">{{ factor.created_at.strftime('%Y-%m-%d %H:%M') if factor.created_at else 'Unknown' }}</dd>
<dt class="col-sm-4 fw-semibold">Last Updated</dt>
<dd class="col-sm-8">{{ factor.updated_at.strftime('%Y-%m-%d %H:%M') if factor.updated_at else 'Never' }}</dd>
{% if factor.updater %}
<dt class="col-sm-4 fw-semibold">Updated By</dt>
<dd class="col-sm-8">{{ factor.updater.username }}</dd>
{% endif %}
</dl>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-light border-0">
<h5 class="card-title mb-0">
<i class="bi bi-lightning me-2"></i>Quick Actions
</h5>
</div>
<div class="card-body">
<a href="{{ url_for('admin.cost_factor_history', factor_id=factor.id) }}"
class="btn btn-outline-info btn-sm w-100 mb-2">
<i class="bi bi-clock-history me-1"></i> View Change History
</a>
<a href="{{ url_for('admin.cost_factors') }}"
class="btn btn-outline-secondary btn-sm w-100">
<i class="bi bi-list me-1"></i> Back to All Factors
</a>
</div>
</div>
</div>
</div>
</div>
<style>
.form-control[readonly] {
background-color: #f8f9fa !important;
border-color: #dee2e6;
color: #6c757d;
}
.form-control:focus {
border-color: #0d6efd;
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25);
}
.badge {
font-size: 0.875em;
}
dl.row dt {
color: #6c757d;
font-size: 0.9rem;
}
dl.row dd {
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.card {
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-1px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.btn {
border-radius: 0.375rem;
}
.form-control-lg {
font-size: 1.1rem;
padding: 0.75rem 1rem;
}
</style>
{% endblock %}
@@ -0,0 +1,146 @@
{% extends "admin/base.html" %}
{% block admin_title %}Quotation Details{% endblock %}
{% block admin_header %}Quotation Details{% endblock %}
{% block admin_actions %}
<a href="{{ url_for('admin.quotations') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Quotations
</a>
{% endblock %}
{% block admin_content %}
{% from "_quotation_details.html" import render_quotation_details %}
{{ render_quotation_details(quotation) }}
{% endblock %}
{% block styles %}
{{ super() }}
<style>
.quotation-details {
display: flex;
flex-direction: column;
gap: 30px;
}
.detail-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.detail-card h3 {
margin: 0 0 20px;
color: #2c3e50;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.detail-item label {
color: #6c757d;
font-size: 0.9rem;
}
.detail-item span {
font-size: 1.1rem;
color: #2c3e50;
}
.cost-breakdown {
display: flex;
flex-direction: column;
gap: 15px;
}
.cost-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #dee2e6;
}
.cost-item:last-child {
border-bottom: none;
}
.cost-item label {
color: #6c757d;
font-size: 1rem;
}
.cost-item span {
font-size: 1.1rem;
color: #2c3e50;
font-weight: 500;
}
.cost-item.total {
margin-top: 10px;
padding-top: 20px;
border-top: 2px solid #dee2e6;
}
.cost-item.total label {
font-size: 1.2rem;
font-weight: 600;
color: #2c3e50;
}
.cost-item.total span {
font-size: 1.4rem;
font-weight: 700;
color: #28a745;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 5px;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn-secondary:hover {
background-color: #5a6268;
}
.badge {
padding: 5px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
}
.badge-success {
background-color: #28a745;
color: white;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
+174
View File
@@ -0,0 +1,174 @@
{% extends "admin/base.html" %}
{% block title %}Quotations - Admin{% endblock %}
{% block content %}
<div class="admin-container">
<div class="admin-card">
<div class="admin-card-header">
<h2 class="mb-0">Quotations Management</h2>
</div>
<div class="admin-card-body">
<div class="admin-filters">
<form method="get" class="admin-filter-form" id="quotationFilterForm">
<div class="admin-form-group">
<select name="country" class="admin-form-control">
<option value="">All Countries</option>
{% for country in countries %}
<option value="{{ country }}" {% if filters.country == country %}selected{% endif %}>
{{ country }}
</option>
{% endfor %}
</select>
</div>
<div class="admin-form-group">
<select name="mold_type" class="admin-form-control">
<option value="">All Mold Types</option>
{% for type in mold_types %}
<option value="{{ type }}" {% if filters.mold_type == type %}selected{% endif %}>
{{ type }}
</option>
{% endfor %}
</select>
</div>
<div class="admin-form-group">
<input type="date" name="date_from" id="date_from" class="admin-form-control"
value="{{ filters.date_from.strftime('%Y-%m-%d') if filters.date_from else '' }}"
placeholder="From Date">
</div>
<div class="admin-form-group">
<input type="date" name="date_to" id="date_to" class="admin-form-control"
value="{{ filters.date_to.strftime('%Y-%m-%d') if filters.date_to else '' }}"
placeholder="To Date">
</div>
<div class="admin-form-group">
<button type="submit" class="admin-btn admin-btn-primary">Filter</button>
<a href="{{ url_for('admin.quotations') }}" class="admin-btn admin-btn-secondary">Clear</a>
</div>
</form>
</div>
{% if quotations.items %}
<div class="table-responsive">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Model</th>
<th>Country</th>
<th>Mold Type</th>
<th>Base Price</th>
<th>Total Cost</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for quotation in quotations.items %}
<tr>
<td>{{ quotation.id }}</td>
<td>{{ quotation.user.username }}</td>
<td>{{ quotation.model_name }}</td>
<td>{{ quotation.mold_shop_country }}</td>
<td>{{ quotation.mold_main_type }}</td>
<td>${{ "%.2f"|format(quotation.base_price) }}</td>
<td>${{ "%.2f"|format(quotation.total_cost) }}</td>
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<a href="{{ url_for('admin.quotation_detail', id=quotation.id) }}"
class="admin-btn admin-btn-info admin-btn-sm">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if quotations.pages > 1 %}
<div class="admin-pagination">
{% if quotations.has_prev %}
<a href="{{ url_for('admin.quotations', page=quotations.prev_num, **filters) }}"
class="admin-pagination-link">Previous</a>
{% endif %}
<span class="admin-pagination-info">
Page {{ quotations.page }} of {{ quotations.pages }}
</span>
{% if quotations.has_next %}
<a href="{{ url_for('admin.quotations', page=quotations.next_num, **filters) }}"
class="admin-pagination-link">Next</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="text-center py-4">
<p class="text-muted mb-0">No quotations found matching your criteria.</p>
{% if filters.country or filters.mold_type or filters.date_from or filters.date_to %}
<a href="{{ url_for('admin.quotations') }}" class="admin-btn admin-btn-primary mt-3">Clear Filters</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Function to get today's date in YYYY-MM-DD format
function getTodayDate() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// Get date input elements
const dateFromInput = document.getElementById('date_from');
const dateToInput = document.getElementById('date_to');
const form = document.getElementById('quotationFilterForm');
// Set default values if empty
if (!dateFromInput.value) {
dateFromInput.value = getTodayDate();
}
if (!dateToInput.value) {
dateToInput.value = getTodayDate();
}
// Prevent form submission if dates are invalid
form.addEventListener('submit', function(event) {
if (!dateFromInput.value || !dateToInput.value) {
event.preventDefault();
if (!dateFromInput.value) dateFromInput.value = getTodayDate();
if (!dateToInput.value) dateToInput.value = getTodayDate();
}
});
// Add event listeners to handle invalid dates
dateFromInput.addEventListener('change', function() {
if (!this.value) this.value = getTodayDate();
});
dateToInput.addEventListener('change', function() {
if (!this.value) this.value = getTodayDate();
});
});
</script>
{% endblock %}
{% block styles %}
{{ super() }}
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
{% endblock %}
+1
View File
@@ -0,0 +1 @@
+253
View File
@@ -0,0 +1,253 @@
{% extends "admin/base.html" %}
{% block title %}Users - Admin Panel{% endblock %}
{% block content %}
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-8">
<label for="search" class="form-label">Search Users</label>
<input type="text" id="search" name="search" placeholder="Search by username or email..."
value="{{ request.args.get('search', '') }}" class="form-control">
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-search me-2"></i>Search
</button>
</div>
</form>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent border-0">
<h5 class="card-title mb-0">
<i class="fas fa-users me-2 text-primary"></i>
User Management
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="border-0">ID</th>
<th class="border-0">Username</th>
<th class="border-0">Email</th>
<th class="border-0">Status</th>
<th class="border-0">Role</th>
<th class="border-0">Last Login</th>
<th class="border-0">Created</th>
<th class="border-0 text-center">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users.items %}
<tr>
<td>
<span class="badge bg-secondary">#{{ user.id }}</span>
</td>
<td>
<div class="d-flex align-items-center">
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
<i class="fas fa-user text-primary"></i>
</div>
<span class="fw-medium">{{ user.username }}</span>
</div>
</td>
<td>
<span class="text-muted">{{ user.email }}</span>
</td>
<td>
<span class="badge bg-{{ 'success' if user.is_active else 'danger' }}">
<i class="fas fa-{{ 'check-circle' if user.is_active else 'times-circle' }} me-1"></i>
{{ 'Active' if user.is_active else 'Inactive' }}
</span>
</td>
<td>
<span class="badge bg-{{ 'primary' if user.is_admin else 'secondary' }}">
<i class="fas fa-{{ 'crown' if user.is_admin else 'user' }} me-1"></i>
{{ 'Admin' if user.is_admin else 'User' }}
</span>
</td>
<td>
<small class="text-muted">
{{ user.last_login.strftime('%m/%d/%Y %H:%M') if user.last_login else 'Never' }}
</small>
</td>
<td>
<small class="text-muted">
{{ user.created_at.strftime('%m/%d/%Y') }}
</small>
</td>
<td class="text-center">
<div class="btn-group" role="group">
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}"
class="btn btn-outline-info btn-sm"
title="View Details">
<i class="fas fa-eye"></i>
</a>
<button type="button"
class="btn btn-{{ 'warning' if user.is_active else 'success' }} btn-sm"
onclick="toggleUserStatus({{ user.id }}, '{{ 'warning' if user.is_active else 'success' }}')"
title="{{ 'Deactivate' if user.is_active else 'Activate' }} User">
<i class="fas fa-{{ 'ban' if user.is_active else 'check' }}"></i>
</button>
{% if user.id != current_user.id %}
<button type="button"
class="btn btn-outline-danger btn-sm"
onclick="deleteUser({{ user.id }})"
title="Delete User">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if users.pages > 1 %}
<div class="d-flex justify-content-between align-items-center mt-4">
<div class="text-muted">
Showing {{ users.items|length }} of {{ users.total }} users
</div>
<nav aria-label="User pagination">
<ul class="pagination pagination-sm mb-0">
{% if users.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.users', page=users.prev_num, search=request.args.get('search', '')) }}">
<i class="fas fa-chevron-left"></i>
</a>
</li>
{% endif %}
{% for page_num in users.iter_pages() %}
{% if page_num %}
{% if page_num != users.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.users', page=page_num, search=request.args.get('search', '')) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if users.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.users', page=users.next_num, search=request.args.get('search', '')) }}">
<i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script>
function toggleUserStatus(userId, status) {
const button = event.target.closest('button');
const isActive = status === 'warning';
const action = isActive ? 'deactivate' : 'activate';
if (!confirm(`Are you sure you want to ${action} this user?`)) {
return;
}
fetch(`/admin/user/${userId}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
location.reload();
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while updating user status');
});
}
function deleteUser(userId) {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
return;
}
fetch('/admin/delete_user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({ user_id: userId })
})
.then(response => response.json())
.then(data => {
if (data.message) {
location.reload();
} else {
alert('Error: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while deleting the user');
});
}
</script>
{% endblock %}
{% block styles %}
{{ super() }}
<style>
.avatar-sm {
font-size: 0.875rem;
}
.table th {
font-weight: 600;
color: #495057;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.table td {
vertical-align: middle;
}
.btn-group .btn {
margin-right: 2px;
}
.btn-group .btn:last-child {
margin-right: 0;
}
</style>
{% endblock %}
+168
View File
@@ -0,0 +1,168 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-4">
<h2 class="mb-4">Admin Management</h2>
<!-- User Management Section -->
<div class="card mb-4">
<div class="card-header">
<h3 class="mb-0">User Management</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Email</th>
<th>Company</th>
<th>Registration Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td>{{ user.company }}</td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
{% if user.is_admin %}
<span class="badge bg-primary">Admin</span>
{% else %}
<span class="badge bg-success">User</span>
{% endif %}
</td>
<td>
{% if not user.is_admin %}
<button class="btn btn-danger btn-sm"
onclick="deleteUser({{ user.id }})"
data-bs-toggle="modal"
data-bs-target="#deleteModal">
Delete
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Quotation Management Section -->
<div class="card">
<div class="card-header">
<h3 class="mb-0">Quotation Management</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Model</th>
<th>Tooling ID</th>
<th>Total Cost</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for quotation in quotations %}
<tr>
<td>{{ quotation.id }}</td>
<td>{{ quotation.user.username }}</td>
<td>{{ quotation.model_name }}</td>
<td>{{ quotation.tooling_id }}</td>
<td>${{ "%.2f"|format(quotation.total_cost) }}</td>
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<button class="btn btn-danger btn-sm"
onclick="deleteQuotation({{ quotation.id }})"
data-bs-toggle="modal"
data-bs-target="#deleteModal">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this item?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDelete">Delete</button>
</div>
</div>
</div>
</div>
<script>
let itemToDelete = null;
let deleteType = null;
function deleteUser(userId) {
itemToDelete = userId;
deleteType = 'user';
}
function deleteQuotation(quoteId) {
itemToDelete = quoteId;
deleteType = 'quotation';
}
document.getElementById('confirmDelete').addEventListener('click', function() {
if (!itemToDelete || !deleteType) return;
const url = deleteType === 'user' ? '/admin/delete_user' : '/admin/delete_quotation';
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ id: itemToDelete })
})
.then(response => response.json())
.then(data => {
if (data.message) {
location.reload();
} else {
alert('Error deleting item');
}
})
.catch(error => {
console.error('Error:', error);
alert('Error deleting item');
})
.finally(() => {
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
modal.hide();
});
});
</script>
{% endblock %}
+128
View File
@@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}Admin Portal - MoldCost Pro{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="fas fa-lock me-2"></i>Quotation Records</h2>
<div>
<a href="{{ url_for('calculator') }}" class="btn btn-secondary">
<i class="fas fa-calculator me-2"></i>Calculator
</a>
<a href="{{ url_for('logout') }}" class="btn btn-danger">
<i class="fas fa-sign-out-alt me-2"></i>Logout
</a>
</div>
</div>
<div class="card shadow">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Company</th>
<th>User</th>
<th class="text-end">Model</th>
<th class="text-end">Tooling ID</th>
<th class="text-end">Total</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for quotation in quotations %}
<tr>
<td>{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ quotation.user.company }}</td>
<td>{{ quotation.user.username }}</td>
<td class="text-end">{{ quotation.model_name }}</td>
<td class="text-end">{{ quotation.tooling_id }}</td>
<td class="text-end fw-bold">${{ "{:,.2f}".format(quotation.total_cost) }}</td>
<td>
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="modal"
data-bs-target="#detailModal{{ quotation.id }}">
<i class="fas fa-search"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% for quotation in quotations %}
<div class="modal fade" id="detailModal{{ quotation.id }}" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Quotation Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-4">
<div class="col-md-6">
<h6 class="text-muted mb-3">Basic Information</h6>
<dl class="row">
<dt class="col-sm-4">Date</dt>
<dd class="col-sm-8">{{ quotation.created_at.strftime('%Y-%m-%d %H:%M') }}</dd>
<dt class="col-sm-4">Company</dt>
<dd class="col-sm-8">{{ quotation.user.company }}</dd>
<dt class="col-sm-4">User</dt>
<dd class="col-sm-8">{{ quotation.user.username }}</dd>
<dt class="col-sm-4">Model</dt>
<dd class="col-sm-8">{{ quotation.model_name }}</dd>
<dt class="col-sm-4">Tooling ID</dt>
<dd class="col-sm-8">{{ quotation.tooling_id }}</dd>
</dl>
</div>
<div class="col-md-6">
<h6 class="text-muted mb-3">Mold Specifications</h6>
<dl class="row">
<dt class="col-sm-4">Mold Type</dt>
<dd class="col-sm-8">{{ quotation.mold_main_type }} - {{ quotation.mold_sub_type }}</dd>
<dt class="col-sm-4">Cavities</dt>
<dd class="col-sm-8">{{ quotation.cavity_count }} cavity</dd>
<dt class="col-sm-4">Sliders</dt>
<dd class="col-sm-8">{{ quotation.sliders_count }} slider</dd>
<dt class="col-sm-4">Texture</dt>
<dd class="col-sm-8">{{ quotation.digital_texture_type }}</dd>
<dt class="col-sm-4">High Sidewall</dt>
<dd class="col-sm-8">{{ 'Yes' if quotation.complexity_high_sidewall else 'No' }}</dd>
</dl>
</div>
</div>
<div class="row">
<div class="col-12">
<h6 class="text-muted mb-3">Cost Breakdown</h6>
<dl class="row">
<dt class="col-sm-4">Base Price</dt>
<dd class="col-sm-8">${{ "{:,.2f}".format(quotation.net_base_price) }}</dd>
<dt class="col-sm-4">Tax Rate</dt>
<dd class="col-sm-8">{{ "{:.1%}".format(quotation.tax_rate) }}</dd>
<dt class="col-sm-4">Total Cost</dt>
<dd class="col-sm-8 fw-bold">${{ "{:,.2f}".format(quotation.total_cost) }}</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
+58
View File
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Login - Mold Cost Calculator{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">Login</h4>
</div>
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
<div class="d-grid gap-2">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
<div class="card-footer text-center">
<p class="mb-0">Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a></p>
<p class="mb-0 mt-2"><a href="{{ url_for('auth.request_reset') }}">Forgot your password?</a></p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Register - Mold Cost Calculator{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">Register</h4>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('auth.register') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
{% for error in form.username.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.company.label(class="form-label") }}
{{ form.company(class="form-control" + (" is-invalid" if form.company.errors else "")) }}
{% for error in form.company.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password_confirm.label(class="form-label") }}
{{ form.password_confirm(class="form-control" + (" is-invalid" if form.password_confirm.errors else "")) }}
{% for error in form.password_confirm.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3 form-check">
{{ form.terms(class="form-check-input" + (" is-invalid" if form.terms.errors else "")) }}
{{ form.terms.label(class="form-check-label") }}
{% for error in form.terms.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="d-grid gap-2">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
<div class="card-footer text-center">
<p class="mb-0">Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a></p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Request Password Reset - Mold Cost Calculator{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">Request Password Reset</h4>
</div>
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.request_reset') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else "")) }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="d-grid gap-2">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
<div class="card-footer text-center">
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}">Login here</a></p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Reset Password - Mold Cost Calculator{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">Reset Password</h4>
</div>
<div class="card-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-3">
{{ form.password_confirm.label(class="form-label") }}
{{ form.password_confirm(class="form-control" + (" is-invalid" if form.password_confirm.errors else "")) }}
{% for error in form.password_confirm.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="d-grid gap-2">
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
<div class="card-footer text-center">
<p class="mb-0">Remember your password? <a href="{{ url_for('auth.login') }}">Login here</a></p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+156
View File
@@ -0,0 +1,156 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}{% endblock %} - Mold Cost Calculator</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🧮</text></svg>">
<!-- Bootstrap CSS -->
<link href="{{ url_for('static', filename='css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.css') }}">
<!-- Custom Styles -->
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
<!-- Custom Styles -->
<style>
:root {
--primary-color: #0d6efd;
--secondary-color: #6c757d;
--accent-color: #0d6efd;
--success-color: #198754;
--warning-color: #ffc107;
--danger-color: #dc3545;
--light-color: #f8f9fa;
--dark-color: #212529;
--text-color: #212529;
--text-muted: #6c757d;
--border-color: #dee2e6;
--shadow-color: rgba(0, 0, 0, 0.1);
}
body {
font-family: 'Inter', sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: #f8f9fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.navbar {
background-color: var(--primary-color);
padding: 1rem 0;
box-shadow: 0 2px 4px var(--shadow-color);
position: sticky;
top: 0;
z-index: 1000;
}
.navbar-brand {
color: white;
text-decoration: none;
font-size: 1.5rem;
font-weight: 600;
}
.nav-link {
color: rgba(255, 255, 255, 0.9);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 4px;
transition: all 0.3s ease;
}
.nav-link:hover {
color: white;
background-color: rgba(255, 255, 255, 0.2);
}
.nav-link.active {
color: white;
background-color: rgba(255, 255, 255, 0.2);
}
.main-content {
flex: 1;
padding: 2rem 0;
}
.footer {
background-color: var(--primary-color);
color: white;
padding: 1rem 0;
text-align: center;
margin-top: auto;
}
</style>
{% block head %}{% endblock %}
{% block styles %}{% endblock %}
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.home') }}">
<i class="bi bi-calculator me-2"></i>Mold Cost Calculator
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.calculator' %}active{% endif %}"
href="{{ url_for('main.calculator') }}">
<i class="bi bi-calculator me-1"></i>Calculator
</a>
</li>
</ul>
{% if current_user.is_authenticated %}
<div class="d-flex align-items-center">
<span class="text-white me-3">
<i class="bi bi-person me-2"></i>{{ current_user.username }}
</span>
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light">
<i class="bi bi-box-arrow-right me-2"></i>Logout
</a>
</div>
{% endif %}
</div>
</div>
</nav>
<main class="main-content">
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</main>
<footer class="footer">
<div class="container">
<p class="mb-0">PCT & Tooling. All rights reserved.</p>
</div>
</footer>
<!-- Bootstrap Bundle with Popper -->
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<!-- jQuery -->
<script src="{{ url_for('static', filename='js/jquery.min.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+215
View File
@@ -0,0 +1,215 @@
{% extends "base.html" %}
{% block head %}
<meta name="csrf-token" content="{{ csrf_token() }}">
{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Mold Cost Calculator</h2>
{% if current_user.is_authenticated and current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-primary">
<i class="fas fa-cog me-2"></i>Admin Portal
</a>
{% endif %}
</div>
<form id="calcForm" class="row g-3" method="POST" action="/calculate">
{{ form.csrf_token }}
<!-- Factory Information -->
<div class="card mb-4">
<div class="card-body">
<h6 class="card-title mb-3">Factory Information</h6>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
{{ form.mold_shop_name.label(class="form-label") }}
{{ form.mold_shop_name(class="form-control") }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
{{ form.mold_shop_country.label(class="form-label") }}
{{ form.mold_shop_country(class="form-control") }}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
{{ form.t1_shoe_factory_name.label(class="form-label") }}
{{ form.t1_shoe_factory_name(class="form-control") }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
{{ form.t1_shoe_factory_country.label(class="form-label") }}
{{ form.t1_shoe_factory_country(class="form-control") }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="form-group">
{{ form.t2_component_factory_name.label(class="form-label") }}
{{ form.t2_component_factory_name(class="form-control") }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
{{ form.t2_component_factory_country.label(class="form-label") }}
{{ form.t2_component_factory_country(class="form-control") }}
</div>
</div>
</div>
</div>
</div>
<!-- Mold Information -->
<div class="card mb-4">
<div class="card-body">
<h6 class="card-title mb-3">Mold Information</h6>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
{{ form.model_name.label(class="form-label") }}
{{ form.model_name(class="form-control") }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
{{ form.tooling_id.label(class="form-label") }}
{{ form.tooling_id(class="form-control") }}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<div class="form-group">
{{ form.season.label(class="form-label") }}
{{ form.season(class="form-control", id="season") }}
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form.stage.label(class="form-label") }}
{{ form.stage(class="form-control", id="stage") }}
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form.type.label(class="form-label") }}
{{ form.type(class="form-control", id="type") }}
</div>
</div>
</div>
</div>
</div>
<!-- Mold Specifications -->
<div class="card mb-4">
<div class="card-body">
<h6 class="card-title mb-3">Mold Specifications</h6>
<div class="row mb-3">
<div class="col-md-4">
<div class="form-group">
{{ form.mold_main_type.label(class="form-label") }}
{{ form.mold_main_type(class="form-control", id="mold_main_type") }}
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form.mold_sub_type.label(class="form-label") }}
{{ form.mold_sub_type(class="form-control", id="mold_sub_type") }}
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form.mold_specific_type.label(class="form-label") }}
{{ form.mold_specific_type(class="form-control", id="mold_specific_type") }}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
{{ form.sliders_count.label(class="form-label") }}
{{ form.sliders_count(class="form-control") }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
{{ form.cavity_count.label(class="form-label") }}
{{ form.cavity_count(class="form-control") }}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
{{ form.digital_texture_type.label(class="form-label") }}
{{ form.digital_texture_type(class="form-control") }}
</div>
</div>
<div class="col-md-6">
<div class="form-group">
{{ form.complexity_high_sidewall.label(class="form-label") }}
{{ form.complexity_high_sidewall(class="form-control") }}
</div>
</div>
</div>
</div>
</div>
<!-- Dates & Approvals -->
<div class="card mb-4">
<div class="card-body">
<h6 class="card-title mb-3">Dates & Approvals</h6>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-group">
{{ form.issue_date.label(class="form-label") }}
{{ form.issue_date(class="form-control", type="date") }}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<div class="form-group">
{{ form.approved_by_mold_shop.label(class="form-label") }}
{{ form.approved_by_mold_shop(class="form-control") }}
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form.approved_by_t1_tooling.label(class="form-label") }}
{{ form.approved_by_t1_tooling(class="form-control") }}
</div>
</div>
<div class="col-md-4">
<div class="form-group">
{{ form.approved_by_lo_tooling.label(class="form-label") }}
{{ form.approved_by_lo_tooling(class="form-control") }}
</div>
</div>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="text-center">
<button type="submit" id="calculateBtn" class="btn btn-primary btn-lg">Calculate</button>
</div>
</form>
<!-- Results Section -->
<div id="result" class="mt-4"></div>
</div>
{% endblock %}
{% block scripts %}
{{ super() }}
<script id="mold-calculator-script" src="{{ url_for('static', filename='js/mold_calculator.js', v='1.4') }}"></script>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">Error {{ code }}</h4>
</div>
<div class="card-body">
<h5 class="card-title">{{ message }}</h5>
<p class="card-text">
{% if code == 404 %}
The page you're looking for doesn't exist.
{% elif code == 403 %}
You don't have permission to access this resource.
{% elif code == 400 %}
The request was invalid.
{% else %}
An unexpected error occurred. Please try again later.
{% endif %}
</p>
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return to Home</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Bad Request - MoldCost Pro{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="text-center">
<h1 class="display-1">400</h1>
<h2 class="mb-4">Bad Request</h2>
<p class="lead">The request was invalid or cannot be served.</p>
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
</div>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Forbidden - MoldCost Pro{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="text-center">
<h1 class="display-1">403</h1>
<h2 class="mb-4">Access Forbidden</h2>
<p class="lead">You don't have permission to access this resource.</p>
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
</div>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Not Found - MoldCost Pro{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="text-center">
<h1 class="display-1">404</h1>
<h2 class="mb-4">Page Not Found</h2>
<p class="lead">The page you're looking for doesn't exist.</p>
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
</div>
</div>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-warning text-white">
<h4 class="mb-0">Conflict</h4>
</div>
<div class="card-body">
<h5 class="card-title">Data Constraint Error</h5>
<p class="card-text">{{ message }}</p>
<p class="card-text">This error typically occurs when trying to create a duplicate record or violate a data constraint.</p>
<p class="card-text">Please check your input and try again.</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Return to Home</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+14
View File
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block title %}Server Error - MoldCost Pro{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="text-center">
<h1 class="display-1">500</h1>
<h2 class="mb-4">Server Error</h2>
<p class="lead">Something went wrong on our end. Please try again later.</p>
<a href="{{ url_for('main.home') }}" class="btn btn-primary">Return Home</a>
</div>
</div>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header bg-warning text-white">
<h4 class="mb-0">Service Temporarily Unavailable</h4>
</div>
<div class="card-body">
<h5 class="card-title">Database Connection Error</h5>
<p class="card-text">{{ message }}</p>
<p class="card-text">Our technical team has been notified and is working to resolve this issue.</p>
<p class="card-text">Please try again in a few minutes.</p>
<a href="{{ url_for('main.index') }}" class="btn btn-primary">Return to Home</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block content %}
<div class="container-main py-5">
<div class="hero-section bg-gradient-primary text-white rounded-3 p-5 shadow-lg">
<div class="text-center">
<h1 class="display-4 fw-bold mb-4">
<i class="bi bi-gear-connected"></i>
MoldCost Pro
</h1>
<p class="lead mb-4">Precision Mold Cost Calculations</p>
<div class="d-grid gap-3 d-md-flex justify-content-md-center">
{% if current_user.is_authenticated %}
<a href="{{ url_for('calculator') }}" class="btn btn-light btn-lg px-4">
<i class="bi bi-calculator me-2"></i>
Go to Calculator
</a>
{% else %}
<a href="{{ url_for('login') }}" class="btn btn-light btn-lg px-4">
<i class="bi bi-box-arrow-in-right me-2"></i>
Get Started
</a>
<a href="{{ url_for('register') }}" class="btn btn-outline-light btn-lg px-4">
<i class="bi bi-person-plus me-2"></i>
Register
</a>
{% endif %}
</div>
</div>
</div>
<!-- Features Section -->
<div class="row g-4 mt-5">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<i class="bi bi-speedometer2 display-4 text-primary"></i>
<h3 class="mt-3">Fast Calculation</h3>
<p class="text-muted">Instant mold cost estimates with industry-standard algorithms</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<i class="bi bi-clock-history display-4 text-primary"></i>
<h3 class="mt-3">History Tracking</h3>
<p class="text-muted">Review your previous quotes and calculations</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center">
<i class="bi bi-shield-lock display-4 text-primary"></i>
<h3 class="mt-3">Secure Data</h3>
<p class="text-muted">Enterprise-grade security for your sensitive data</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block styles %}
<style>
.hero-section {
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
position: relative;
overflow: hidden;
}
.hero-section:before {
content: "";
position: absolute;
top: -50%;
left: -50%;
right: -50%;
bottom: -50%;
background: linear-gradient(45deg,
rgba(255,255,255,0.1) 25%,
transparent 25%,
transparent 50%,
rgba(255,255,255,0.1) 50%,
rgba(255,255,255,0.1) 75%,
transparent 75%,
transparent
);
background-size: 40px 40px;
animation: animateStripes 4s linear infinite;
opacity: 0.15;
}
@keyframes animateStripes {
0% { transform: translateY(0); }
100% { transform: translateY(-40px); }
}
</style>
{% endblock %}
+82
View File
@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Commercial Invoice - {{ quote.id }} | MoldCost Pro</title>
<style>
:root {
--primary-color: #1a365d;
--secondary-color: #2c5282;
--accent-color: #c53030;
}
body {
font-family: 'Helvetica Neue', Arial, sans-serif;
color: #1a202c;
line-height: 1.6;
margin: 0;
padding: 20px;
}
.letterhead {
border-bottom: 2px solid var(--primary-color);
margin-bottom: 2rem;
padding-bottom: 1.5rem;
}
.company-logo {
height: 60px;
margin-bottom: 1rem;
}
.legal-disclaimer {
font-size: 0.75rem;
color: #718096;
margin-top: 3rem;
}
@media print {
.page-break { page-break-before: always; }
}
</style>
</head>
<body>
<div class="letterhead">
<img src="https://cdn.example.com/logo.svg"
alt="MoldCost Pro"
class="company-logo">
<div class="flex justify-between text-sm text-gray-600">
<div>
<p>MoldCost Pro Limited</p>
<p>123 Manufacturing Blvd, Shenzhen, CN</p>
</div>
<div class="text-right">
<p>T: +86 (755) 1234-5678</p>
<p>E: sales@moldcostpro.com</p>
<p>W: www.moldcostpro.com</p>
</div>
</div>
</div>
<div class="header-section">
<h1 class="text-2xl font-bold">Commercial Invoice</h1>
<div class="flex justify-between text-sm text-gray-600 mt-2">
<div>
<p><strong>Quote ID:</strong> MC-{{ quote.id|string|replace(' ', '0') }}</p>
<p><strong>Date:</strong> {{ quote.created_at.strftime('%d-%b-%Y') }}</p>
</div>
<div>
<p><strong>Currency:</strong> USD</p>
<p><strong>Valid Until:</strong> {{ (quote.created_at + datetime.timedelta(days=30)).strftime('%d-%b-%Y') }}</p>
</div>
</div>
</div>
{% from "_quotation_details.html" import render_quotation_details %}
{{ render_quotation_details(quote, show_actions=false) }}
<div class="legal-disclaimer">
<p>This quotation is subject to our standard terms and conditions. Prices valid for 30 days from quotation date. All measurements tolerances ±0.5% unless otherwise specified. Export documentation extra.</p>
</div>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Terms of Service - Mold Cost Online Tool{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow">
<div class="card-body p-5">
<h3 class="mb-4 text-center">Terms of Service</h3>
<div class="terms-content">
<h4 class="mb-3">1. Acceptance of Terms</h4>
<p>By accessing and using the Mold Cost Online Tool, you agree to be bound by these Terms of Service.</p>
<h4 class="mb-3 mt-4">2. Use of Service</h4>
<p>The Mold Cost Online Tool is provided for professional use in the footwear industry. Users must:</p>
<ul>
<li>Provide accurate and complete information</li>
<li>Maintain the confidentiality of their account</li>
<li>Use the service in compliance with all applicable laws</li>
</ul>
<h4 class="mb-3 mt-4">3. Data Privacy</h4>
<p>We are committed to protecting your data:</p>
<ul>
<li>All calculations and quotations are stored securely</li>
<li>Personal information is used only for service provision</li>
<li>Data is not shared with third parties without consent</li>
</ul>
<h4 class="mb-3 mt-4">4. Intellectual Property</h4>
<p>The Mold Cost Online Tool and its contents are protected by intellectual property rights. Users may not:</p>
<ul>
<li>Copy or reproduce the tool's algorithms</li>
<li>Reverse engineer the service</li>
<li>Use the service for unauthorized commercial purposes</li>
</ul>
<h4 class="mb-3 mt-4">5. Disclaimer</h4>
<p>The tool provides estimates based on industry standards. While we strive for accuracy:</p>
<ul>
<li>Results are estimates only</li>
<li>Actual costs may vary</li>
<li>Users should verify calculations independently</li>
</ul>
<h4 class="mb-3 mt-4">6. Account Termination</h4>
<p>We reserve the right to terminate accounts that:</p>
<ul>
<li>Violate these terms</li>
<li>Engage in fraudulent activity</li>
<li>Misuse the service</li>
</ul>
<div class="mt-5 text-center">
<a href="{{ url_for('register') }}" class="btn btn-primary">
<i class="bi bi-arrow-left me-2"></i>Back to Registration
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
View File
+56
View File
@@ -0,0 +1,56 @@
from ..utils.cost_factor_loader import get_cost_factors
def calculate_base_price(mold_main_type, mold_sub_type, mold_specific_type, sliders_count, cavity_count, digital_texture_type, complexity_high_sidewall=False):
"""
Calculate the base price for a mold based on various factors.
Args:
mold_main_type (str): The main type of the mold
mold_sub_type (str): The sub type of the mold
mold_specific_type (str): The specific type of the mold
sliders_count (int): Number of sliders
cavity_count (int): Number of cavities
digital_texture_type (str): Type of digital texture
complexity_high_sidewall (bool): Whether the mold has high sidewall complexity
Returns:
dict: A dictionary containing all cost components
"""
# Get cost factors from database (or fallback to defaults)
cost_factors, tax_rates, default_net_base = get_cost_factors()
# Start with the default net base
base_price = default_net_base
# Apply mold type factor
if mold_specific_type in cost_factors['mold']:
base_price *= cost_factors['mold'][mold_specific_type]
# Calculate complexity cost
complexity_cost = 0
# Add slider complexity
if sliders_count > 0:
complexity_cost += base_price * (sliders_count * cost_factors['complexity']['slider'])
# Add cavity complexity
if cavity_count > 1:
complexity_cost += base_price * ((cavity_count - 1) * cost_factors['complexity']['cavity'])
# Add high sidewall complexity
if complexity_high_sidewall:
complexity_cost += base_price * cost_factors['complexity']['high_sidewall']
# Calculate texture cost
texture_cost = 0
if digital_texture_type in cost_factors['texture']:
texture_cost = base_price * cost_factors['texture'][digital_texture_type]
# Calculate total cost
total_cost = base_price + complexity_cost + texture_cost
return {
'base_price': round(base_price, 2),
'complexity_cost': round(complexity_cost, 2),
'texture_cost': round(texture_cost, 2),
'total_cost': round(total_cost, 2)
}
+139
View File
@@ -0,0 +1,139 @@
from ..models import CostFactor
from .. import db
import logging
logger = logging.getLogger(__name__)
# Single source of truth for default cost factors
DEFAULT_COST_FACTORS = [
# Mold Type Factors
{'category': 'mold', 'name': 'Flat', 'factor_value': 1.0, 'description': 'Standard flat outsole'},
{'category': 'mold', 'name': 'Flat with Top/Heel Tip', 'factor_value': 1.25, 'description': 'Flat with additional tip features'},
{'category': 'mold', 'name': 'Top PL', 'factor_value': 1.05, 'description': 'Top part line mold'},
{'category': 'mold', 'name': 'Visible PL', 'factor_value': 1.28, 'description': 'Visible part line mold'},
{'category': 'mold', 'name': 'Short PL (Heel/Forefoot)', 'factor_value': 1.18, 'description': 'Short part line for specific areas'},
{'category': 'mold', 'name': 'Wave PL', 'factor_value': 1.32, 'description': 'Wave pattern part line'},
{'category': 'mold', 'name': 'Multi-color PL', 'factor_value': 1.35, 'description': 'Multi-color part line mold'},
{'category': 'mold', 'name': '2 Plates Mold', 'factor_value': 1.0, 'description': 'Standard 2-plate configuration'},
{'category': 'mold', 'name': '3 Plates Mold', 'factor_value': 1.35, 'description': 'Complex 3-plate configuration'},
{'category': 'mold', 'name': '2 Plates with in-mold channel', 'factor_value': 1.25, 'description': '2-plate with channel features'},
{'category': 'mold', 'name': 'Breathable + in-mold channel', 'factor_value': 1.45, 'description': 'Breathable with channel features'},
{'category': 'mold', 'name': 'Sandal with Upper Bandage', 'factor_value': 1.15, 'description': 'Sandal with bandage features'},
{'category': 'mold', 'name': '2 Plate without Upper Bandage', 'factor_value': 1.05, 'description': 'Standard 2-plate sandal'},
{'category': 'mold', 'name': 'Co-shot mold', 'factor_value': 1.25, 'description': 'Multi-material injection'},
{'category': 'mold', 'name': 'Foaming mold', 'factor_value': 1.4, 'description': 'Foam injection process'},
# Complexity Factors
{'category': 'complexity', 'name': 'high_sidewall', 'factor_value': 0.18, 'calculation_formula': 'Base Price × 0.18', 'description': 'Additional cost for high sidewall molds'},
{'category': 'complexity', 'name': 'slider', 'factor_value': 0.1, 'calculation_formula': 'Base Price × (Number of Sliders × 0.10)', 'description': 'Cost per slider added to mold'},
{'category': 'complexity', 'name': 'cavity', 'factor_value': 0.12, 'calculation_formula': 'Base Price × ((Number of Cavities - 1) × 0.12)', 'description': 'Cost for additional cavities beyond 1'},
# Texture Factors
{'category': 'texture', 'name': 'adidas_digital', 'factor_value': 0.0, 'percentage': '0%', 'description': 'No additional cost for standard adidas texture'},
{'category': 'texture', 'name': 'customized texture', 'factor_value': 0.15, 'percentage': '15%', 'description': '15% of base price for custom texture'},
{'category': 'texture', 'name': 'laser', 'factor_value': 0.2, 'percentage': '20%', 'description': '20% of base price for laser texture'},
# Tax Rates
{'category': 'tax', 'name': 'China', 'factor_value': 0.06, 'percentage': '6%', 'description': '6% VAT for China'},
{'category': 'tax', 'name': 'Vietnam', 'factor_value': 0.10, 'percentage': '10%', 'description': '10% VAT for Vietnam'},
{'category': 'tax', 'name': 'Indonesia', 'factor_value': 0.07, 'percentage': '7%', 'description': '7% VAT for Indonesia'},
# Base Price
{'category': 'base', 'name': 'DEFAULT_NET_BASE', 'factor_value': 12.5, 'description': 'Default base price for calculations'}
]
def load_cost_factors_from_db():
"""
Load cost factors from database and return them in the same format as constants.
Falls back to hardcoded values if database is not available.
"""
try:
# Get all active cost factors
factors = CostFactor.query.filter_by(is_active=True).all()
if not factors:
logger.warning("No cost factors found in database, using hardcoded defaults")
return get_default_cost_factors()
# Build the cost factors structure
cost_factors = {
'mold': {},
'complexity': {},
'texture': {}
}
tax_rates = {}
default_net_base = 12.5 # Default value
for factor in factors:
if factor.category == 'mold':
cost_factors['mold'][factor.name] = factor.factor_value
elif factor.category == 'complexity':
cost_factors['complexity'][factor.name] = factor.factor_value
elif factor.category == 'texture':
cost_factors['texture'][factor.name] = factor.factor_value
elif factor.category == 'tax':
tax_rates[factor.name] = factor.factor_value
elif factor.category == 'base':
default_net_base = factor.factor_value
logger.info(f"Loaded {len(factors)} cost factors from database")
return cost_factors, tax_rates, default_net_base
except Exception as e:
logger.error(f"Error loading cost factors from database: {str(e)}")
logger.info("Falling back to hardcoded cost factors")
return get_default_cost_factors()
def get_default_cost_factors():
"""Return hardcoded cost factors as fallback - derived from single source of truth"""
cost_factors = {
'mold': {},
'complexity': {},
'texture': {}
}
tax_rates = {}
default_net_base = 12.5
# Build the structure from the single source of truth
for factor_data in DEFAULT_COST_FACTORS:
category = factor_data['category']
name = factor_data['name']
value = factor_data['factor_value']
if category == 'mold':
cost_factors['mold'][name] = value
elif category == 'complexity':
cost_factors['complexity'][name] = value
elif category == 'texture':
cost_factors['texture'][name] = value
elif category == 'tax':
tax_rates[name] = value
elif category == 'base':
default_net_base = value
return cost_factors, tax_rates, default_net_base
def get_cost_factors():
"""Get current cost factors (from database or defaults)"""
return load_cost_factors_from_db()
def initialize_default_cost_factors():
"""Initialize the database with default cost factors if none exist"""
try:
if CostFactor.query.count() == 0:
logger.info("Initializing database with default cost factors")
# Use the single source of truth
for factor_data in DEFAULT_COST_FACTORS:
factor = CostFactor(**factor_data)
db.session.add(factor)
db.session.commit()
logger.info(f"Initialized {len(DEFAULT_COST_FACTORS)} default cost factors")
except Exception as e:
logger.error(f"Error initializing default cost factors: {str(e)}")
db.session.rollback()
raise
+64
View File
@@ -0,0 +1,64 @@
import os
import sys
import ctypes
from ctypes.util import find_library
from pathlib import Path
class DLLLoaderError(Exception):
"""自定义DLL加载异常"""
pass
def setup_dependencies(dll_paths=None):
"""配置DLL搜索路径"""
if dll_paths is None:
dll_paths = [
Path(__file__).parent / 'libs',
Path(os.getenv('LOCALAPPDATA')) / 'MSYS2' / 'mingw64' / 'bin'
]
os.environ['PATH'] = os.pathsep.join([
str(p.resolve()) for p in dll_paths if p.exists()
]) + os.pathsep + os.environ.get('PATH', '')
def load_dll(dll_name, mode=ctypes.DEFAULT_MODE, handle_errors=True):
"""增强版DLL加载函数"""
try:
# 尝试标准加载方式
dll = ctypes.CDLL(dll_name, mode=mode)
print(f"✅ 成功加载 {dll_name}")
return dll
except OSError as e:
if handle_errors:
# 尝试通过PATH查找
lib_path = find_library(dll_name)
if lib_path:
return ctypes.CDLL(lib_path, mode=mode)
# 尝试MSYS2路径
msys_path = Path(os.getenv('LOCALAPPDATA')) / 'MSYS2' / 'mingw64' / 'bin' / dll_name
if msys_path.exists():
return ctypes.CDLL(str(msys_path), mode=mode)
# 生成详细错误报告
path_list = os.environ.get('PATH', '').split(os.pathsep)
raise DLLLoaderError(
f"无法加载 {dll_name}\n"
f"错误详情: {str(e)}\n"
f"搜索路径:\n" + "\n".join(path_list)
)
else:
raise
# 自动配置默认路径
try:
setup_dependencies()
except Exception as e:
print(f"⚠️ 初始化依赖失败: {e}")
if __name__ == '__main__':
# 自测试代码
try:
test_dll = load_dll('gtk-3-0.dll')
print(f"测试DLL句柄: {test_dll._handle}")
except DLLLoaderError as e:
print(f"❌ 自测试失败: {e}")
Executable
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
# A universal deployment script that can be run from a local machine or directly on the server.
#
# - When run locally, it will SSH to the server and execute the deployment.
# - When run on the server, it will execute the deployment directly.
set -e
# --- Configuration ---
REMOTE_USER="jimmyg"
REMOTE_HOST="server1"
REMOTE_DIR="/home/jimmyg/mold_cost_online_tool"
SERVER_HOSTNAME="server1" # The hostname of the production server
# --- Deployment Logic ---
deploy_commands() {
echo ">>> [1/4] Navigating to the application directory..."
cd "$REMOTE_DIR"
echo ">>> [2/4] Pulling latest changes from Git..."
git pull
echo ">>> [3/4] Building new container image with the latest code..."
podman-compose build
echo ">>> [4/4] Restarting services with the updated image..."
podman-compose down
podman-compose up -d
echo ""
echo "✅ Deployment finished successfully on $REMOTE_HOST!"
}
# --- Execution ---
# Get the hostname of the machine this script is running on
current_hostname=$(hostname)
if [ "$current_hostname" == "$SERVER_HOSTNAME" ]; then
echo "🚀 Running deployment script directly on the server ($current_hostname)..."
deploy_commands
else
echo "🚀 Running deployment script from local machine ($current_hostname)..."
echo "Connecting to $REMOTE_USER@$REMOTE_HOST..."
# Using 'bash -s' allows us to execute the function on the remote server.
# We pass the function definition and then call it.
ssh "$REMOTE_USER@$REMOTE_HOST" "$(declare -f deploy_commands); deploy_commands"
fi
+40
View File
@@ -0,0 +1,40 @@
# Flask Configuration
FLASK_APP=run.py
FLASK_ENV=production
FLASK_DEBUG=0
# Security
SECRET_KEY=your_production_secret_key_here
# Database
DATABASE_URL=postgresql://username:password@localhost/mold_cost_calculator
# Email Configuration
MAIL_SERVER=smtp.gmail.com
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=your_production_email@gmail.com
MAIL_PASSWORD=your_production_email_password
MAIL_DEFAULT_SENDER=your_production_email@gmail.com
# Admin Account
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=change_this_to_secure_password
ADMIN_COMPANY="System Administration"
# Logging
LOG_LEVEL=INFO
LOG_TO_STDOUT=False
# Redis Configuration (for rate limiting and caching)
REDIS_URL=redis://localhost:6379/0
RATELIMIT_STORAGE_URL=redis://localhost:6379/0
CACHE_REDIS_URL=redis://localhost:6379/1
# Gunicorn Configuration
WORKERS=4
THREADS=2
TIMEOUT=120
MAX_REQUESTS=1000
MAX_REQUESTS_JITTER=50
View File
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Configuration
BACKUP_DIR="/backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DB_HOST="mold_cost_db"
DB_NAME="mold_cost"
DB_USER="postgres"
DB_PASSWORD="postgres"
# Create backup directory if it doesn't exist
mkdir -p "$BACKUP_DIR"
# Set PostgreSQL password environment variable
export PGPASSWORD="$DB_PASSWORD"
# Create backup with error handling
echo "Creating database backup at $(date)..."
if pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" --no-password | gzip > "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz"; then
echo "Backup completed successfully: backup_$TIMESTAMP.sql.gz"
# Keep only the last 7 backups
echo "Cleaning up old backups..."
ls -t "$BACKUP_DIR"/backup_*.sql.gz | tail -n +8 | xargs -r rm
# Verify backup file exists and has content
if [ -f "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz" ] && [ -s "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz" ]; then
echo "Backup verification successful: $(du -h "$BACKUP_DIR/backup_$TIMESTAMP.sql.gz" | cut -f1)"
else
echo "ERROR: Backup file is empty or missing!"
exit 1
fi
else
echo "ERROR: Backup failed!"
exit 1
fi
# Clear password from environment
unset PGPASSWORD
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
# Backup management script for Mold Cost Calculator
# This script provides easy commands for backup operations
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_header() {
echo -e "${BLUE}=== $1 ===${NC}"
}
# Function to create manual backup
create_backup() {
print_header "Creating Manual Backup"
if podman-compose exec backup /backup.sh; then
print_status "Manual backup completed successfully!"
else
print_error "Manual backup failed!"
exit 1
fi
}
# Function to list backups
list_backups() {
print_header "Available Backups"
if [ -d "./backups" ]; then
if [ "$(ls -A ./backups)" ]; then
ls -lah ./backups/backup_*.sql.gz 2>/dev/null | while read -r line; do
echo " $line"
done
else
print_warning "No backup files found in ./backups directory"
fi
else
print_warning "Backups directory does not exist"
fi
}
# Function to restore from backup
restore_backup() {
local backup_file="$1"
if [ -z "$backup_file" ]; then
print_error "Please specify a backup file to restore from."
echo "Usage: $0 restore <backup_file>"
echo "Example: $0 restore ./backups/backup_20241201_120000.sql.gz"
exit 1
fi
if [ ! -f "$backup_file" ]; then
print_error "Backup file '$backup_file' not found!"
list_backups
exit 1
fi
print_header "Database Restore"
print_warning "This will overwrite the current database!"
echo "Backup file: $backup_file"
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
print_status "Stopping application..."
podman-compose stop app
print_status "Restoring database..."
if podman-compose exec backup /restore.sh restore "/backups/$(basename "$backup_file")"; then
print_status "Database restored successfully!"
print_status "Starting application..."
podman-compose start app
print_status "Restore completed!"
else
print_error "Database restore failed!"
print_status "Starting application..."
podman-compose start app
exit 1
fi
else
print_status "Restore cancelled."
fi
}
# Function to show backup status
show_status() {
print_header "Backup Service Status"
# Check if backup container is running
if podman-compose ps backup | grep -q "Up"; then
print_status "Backup service is running"
else
print_error "Backup service is not running"
fi
# Show recent backup logs
print_header "Recent Backup Logs"
podman-compose logs --tail=10 backup
# List available backups
list_backups
}
# Function to show help
show_help() {
print_header "Backup Management Script"
echo "Usage: $0 [command] [options]"
echo ""
echo "Commands:"
echo " backup - Create a manual backup"
echo " list - List available backups"
echo " restore <backup_file> - Restore database from backup"
echo " status - Show backup service status"
echo " help - Show this help message"
echo ""
echo "Examples:"
echo " $0 backup"
echo " $0 list"
echo " $0 restore ./backups/backup_20241201_120000.sql.gz"
echo " $0 status"
}
# Main script logic
case "${1:-help}" in
"backup"|"create")
create_backup
;;
"list"|"ls")
list_backups
;;
"restore")
restore_backup "$2"
;;
"status")
show_status
;;
"help"|"--help"|"-h"|"")
show_help
;;
*)
print_error "Unknown command '$1'"
echo ""
show_help
exit 1
;;
esac
+44
View File
@@ -0,0 +1,44 @@
import os
import sys
import psycopg2
from pathlib import Path
def get_db_url():
"""Get database URL from environment variables."""
db_user = os.getenv('DB_USER', 'mold_user')
db_password = os.getenv('DB_PASSWORD', 'your_db_password_here')
db_host = os.getenv('DB_HOST', 'localhost')
db_port = os.getenv('DB_PORT', '5434')
db_name = os.getenv('DB_NAME', 'mold_cost_calculator_dev')
return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
def run_migration():
# Get database URL from environment
db_url = get_db_url()
try:
# Connect to the database
conn = psycopg2.connect(db_url)
conn.autocommit = True
cur = conn.cursor()
# Read and execute the migration SQL
migration_file = Path(__file__).parent / 'update_mold_types.sql'
with open(migration_file, 'r') as f:
sql = f.read()
cur.execute(sql)
print("Migration completed successfully!")
except Exception as e:
print(f"Error running migration: {str(e)}")
sys.exit(1)
finally:
if 'cur' in locals():
cur.close()
if 'conn' in locals():
conn.close()
if __name__ == '__main__':
run_migration()
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Exit on error
set -e
# Change to the migrations directory
cd "$(dirname "$0")"
echo "Running database migration..."
python3 run_migration.py
echo "Testing migration..."
python3 test_mold_types.py
echo "Migration completed successfully!"
@@ -0,0 +1,82 @@
import os
import sys
import psycopg2
from pathlib import Path
def get_db_url():
"""Get database URL from environment variables."""
db_user = os.getenv('DB_USER', 'mold_user')
db_password = os.getenv('DB_PASSWORD', 'your_db_password_here')
db_host = os.getenv('DB_HOST', 'localhost')
db_port = os.getenv('DB_PORT', '5434')
db_name = os.getenv('DB_NAME', 'mold_cost_calculator_dev')
return f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
def test_migration():
# Get database URL from environment
db_url = get_db_url()
try:
# Connect to the database
conn = psycopg2.connect(db_url)
cur = conn.cursor()
# Check if the column exists
cur.execute("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'mold_quotations'
AND column_name = 'mold_specific_type'
""")
if cur.fetchone():
print("✅ mold_specific_type column exists")
else:
print("❌ mold_specific_type column does not exist")
return False
# Check if the column is NOT NULL
cur.execute("""
SELECT is_nullable
FROM information_schema.columns
WHERE table_name = 'mold_quotations'
AND column_name = 'mold_specific_type'
""")
is_nullable = cur.fetchone()[0]
if is_nullable == 'NO':
print("✅ mold_specific_type column is NOT NULL")
else:
print("❌ mold_specific_type column is nullable")
return False
# Check if all records have a value
cur.execute("""
SELECT COUNT(*)
FROM mold_quotations
WHERE mold_specific_type IS NULL
""")
null_count = cur.fetchone()[0]
if null_count == 0:
print("✅ All records have a mold_specific_type value")
else:
print(f"❌ Found {null_count} records with NULL mold_specific_type")
return False
print("All tests passed!")
return True
except Exception as e:
print(f"Error testing migration: {str(e)}")
return False
finally:
if 'cur' in locals():
cur.close()
if 'conn' in locals():
conn.close()
if __name__ == '__main__':
success = test_migration()
sys.exit(0 if success else 1)
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# Configuration
BACKUP_DIR="/backups"
DB_HOST="mold_cost_db"
DB_NAME="mold_cost"
DB_USER="postgres"
DB_PASSWORD="postgres"
# Set PostgreSQL password environment variable
export PGPASSWORD="$DB_PASSWORD"
# Function to list available backups
list_backups() {
echo "Available backups:"
ls -la "$BACKUP_DIR"/backup_*.sql.gz 2>/dev/null | while read -r line; do
echo " $line"
done
}
# Function to restore from backup
restore_backup() {
local backup_file="$1"
if [ ! -f "$backup_file" ]; then
echo "ERROR: Backup file '$backup_file' not found!"
list_backups
exit 1
fi
echo "WARNING: This will overwrite the current database!"
echo "Backup file: $backup_file"
read -p "Are you sure you want to continue? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo "Restoring database from backup..."
# Drop and recreate database
echo "Dropping existing database..."
dropdb -h "$DB_HOST" -U "$DB_USER" --no-password "$DB_NAME" 2>/dev/null || true
echo "Creating new database..."
createdb -h "$DB_HOST" -U "$DB_USER" --no-password "$DB_NAME"
echo "Restoring data..."
if gunzip -c "$backup_file" | psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" --no-password; then
echo "Database restored successfully!"
else
echo "ERROR: Database restore failed!"
exit 1
fi
else
echo "Restore cancelled."
fi
}
# Main script logic
if [ $# -eq 0 ]; then
echo "Usage: $0 [list|restore <backup_file>]"
echo ""
echo "Commands:"
echo " list - List available backups"
echo " restore <backup_file> - Restore database from backup file"
echo ""
echo "Examples:"
echo " $0 list"
echo " $0 restore /backups/backup_20241201_120000.sql.gz"
exit 1
fi
case "$1" in
"list")
list_backups
;;
"restore")
if [ -z "$2" ]; then
echo "ERROR: Please specify a backup file to restore from."
echo "Usage: $0 restore <backup_file>"
exit 1
fi
restore_backup "$2"
;;
*)
echo "ERROR: Unknown command '$1'"
echo "Usage: $0 [list|restore <backup_file>]"
exit 1
;;
esac
# Clear password from environment
unset PGPASSWORD
+74
View File
@@ -0,0 +1,74 @@
import multiprocessing
import os
from pathlib import Path
# Create logs directory if it doesn't exist
os.makedirs('logs', exist_ok=True)
# Server socket
bind = os.getenv('GUNICORN_BIND', '0.0.0.0:5002')
backlog = 2048
# Worker processes
# Formula: (2 x number_of_cores) + 1
workers = int(os.getenv('GUNICORN_WORKERS', 13)) # Default to 13 workers
worker_class = 'sync'
worker_connections = 1000
timeout = int(os.getenv('GUNICORN_TIMEOUT', 30))
keepalive = 2
# Performance optimizations
worker_tmp_dir = '/dev/shm' # Use RAM for temporary files
limit_request_line = 4094 # Prevent large request attacks
limit_request_fields = 100 # Limit number of request headers
# Logging
accesslog = 'logs/access.log'
errorlog = 'logs/error.log'
loglevel = os.getenv('LOG_LEVEL', 'debug')
# Process naming
proc_name = 'mold_cost_calculator'
# Server mechanics
daemon = False
pidfile = 'gunicorn.pid'
umask = 0
user = None
group = None
tmp_upload_dir = None
# Server hooks
def on_starting(server):
# Ensure instance directory exists
os.makedirs('instance', exist_ok=True)
def on_reload(server):
pass
def on_exit(server):
pass
# Request limits
max_requests = 1000
max_requests_jitter = 50
# Worker settings
graceful_timeout = 30
preload_app = True
# Environment variables
raw_env = [
'FLASK_APP=run.py',
'FLASK_ENV=production',
'FLASK_DEBUG=0'
]
# Security settings
trusted_proxies = os.getenv('TRUSTED_PROXY_IPS', '127.0.0.1,::1').split(',')
forwarded_allow_ips = ','.join(ip.strip() for ip in trusted_proxies)
secure_scheme_headers = {
'X-FORWARDED-PROTOCOL': 'ssl',
'X-FORWARDED-PROTO': 'https',
'X-FORWARDED-SSL': 'on'
}
+161
View File
@@ -0,0 +1,161 @@
#!/bin/bash
set -e
# Function to check internet connectivity
check_internet() {
if ! curl -s --connect-timeout 5 https://registry-1.docker.io/v2/ > /dev/null; then
echo "⚠️ No internet connection to Docker Hub - will use local images only"
return 1
fi
return 0
}
# Function to pull image with retries
pull_image() {
local image=$1
local max_attempts=3
local attempt=1
local wait_time=5
while [ $attempt -le $max_attempts ]; do
echo "📥 Attempting to pull $image (Attempt $attempt/$max_attempts)..."
if podman pull $image; then
echo "✅ Successfully pulled $image"
return 0
fi
echo "⚠️ Pull attempt $attempt failed. Waiting before retry..."
sleep $wait_time
attempt=$((attempt + 1))
wait_time=$((wait_time * 2))
done
return 1
}
# Function to clean up while preserving base images
cleanup_with_preserve() {
echo "🧹 Cleaning up while preserving base images..."
# Save the base image IDs if they exist
local python_image_id=$(podman images -q python:3.12-slim)
local postgres_image_id=$(podman images -q postgres:16-alpine)
# Run system prune
podman system prune -af
# If we had base images, pull them again
if [ ! -z "$python_image_id" ]; then
echo "🔄 Restoring Python base image..."
pull_image python:3.12-slim
fi
if [ ! -z "$postgres_image_id" ]; then
echo "🔄 Restoring PostgreSQL base image..."
pull_image postgres:16-alpine
fi
}
echo "🚀 Starting local deployment process..."
# Check available disk space
echo "💾 Checking disk space..."
AVAILABLE_SPACE=$(df -h . | awk 'NR==2 {print $4}' | tr -d 'G' | tr -d 'i')
if [ "$AVAILABLE_SPACE" -lt 10 ]; then
echo "⚠️ Low disk space detected (${AVAILABLE_SPACE}G available). Cleaning up..."
cleanup_with_preserve
echo "✅ Cleanup complete"
fi
# Check for required base images
echo "🔍 Checking for required base images..."
for image in "python:3.12-slim" "postgres:16-alpine"; do
if podman images | grep -q "$image"; then
echo "$image found locally"
else
echo "⚠️ $image not found locally"
if ! check_internet; then
echo "⚠️ No internet connection - attempting to build without base image"
else
echo "📦 Pulling $image from Docker Hub..."
if ! pull_image "$image"; then
echo "⚠️ Failed to pull $image - attempting to build without it"
fi
fi
fi
done
# Create necessary directories and set permissions
echo "📁 Setting up directories..."
mkdir -p instance logs db_data db_backups
chmod -R 777 instance logs db_data db_backups
# Generate a secure secret key if not exists
if [ ! -f .env ]; then
echo "🔑 Generating new secret key..."
echo "SECRET_KEY=$(openssl rand -hex 32)" > .env
echo "DATABASE_URL=postgresql://mold_user:mold_password@db:5432/mold_cost_calculator" >> .env
fi
# Clear template cache
echo "🧹 Clearing template cache..."
find . -type d -name "__pycache__" -exec rm -r {} +
find . -type f -name "*.pyc" -delete
# Stop and remove existing containers
echo "🧹 Cleaning up existing containers..."
podman-compose down 2>/dev/null || true
# Build the containers with retry logic
echo "📦 Building containers..."
max_build_attempts=3
build_attempt=1
while [ $build_attempt -le $max_build_attempts ]; do
echo "🏗️ Build attempt $build_attempt/$max_build_attempts..."
if podman-compose build; then
echo "✅ Build successful"
break
fi
if [ $build_attempt -eq $max_build_attempts ]; then
echo "❌ Build failed after $max_build_attempts attempts"
exit 1
fi
echo "⚠️ Build attempt $build_attempt failed. Waiting before retry..."
sleep $((build_attempt * 5))
build_attempt=$((build_attempt + 1))
done
# Start the containers
echo "🚀 Starting containers..."
podman-compose up -d
# Wait for database to be ready
echo "⏳ Waiting for database to be ready..."
max_wait=30
wait_count=0
while [ $wait_count -lt $max_wait ]; do
if podman exec mold_cost_db pg_isready -U mold_user -d mold_cost_calculator >/dev/null 2>&1; then
echo "✅ Database is ready"
break
fi
echo "⏳ Waiting for database... ($((wait_count + 1))/$max_wait)"
sleep 1
wait_count=$((wait_count + 1))
done
if [ $wait_count -eq $max_wait ]; then
echo "❌ Database failed to start within timeout"
echo "📝 Checking container logs..."
podman-compose logs db
exit 1
fi
# Check if the containers are running
if podman-compose ps | grep -q "Up"; then
echo "✅ Deployment complete!"
echo "🌐 Application is available at: http://localhost:5003"
echo "📝 To view logs: podman-compose logs"
echo "📝 To view app logs: podman-compose logs web"
echo "📝 To view db logs: podman-compose logs db"
echo "🛑 To stop: podman-compose down"
else
echo "❌ Containers failed to start. Check logs with: podman-compose logs"
exit 1
fi
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=Mold Cost Calculator
After=network.target
[Service]
User=jimmyg
Group=jimmyg
WorkingDirectory=/home/jimmyg/mold_cost_online_app
Environment="PATH=/home/jimmyg/mold_cost_online_app/venv/bin"
Environment="FLASK_APP=run.py"
Environment="FLASK_ENV=production"
ExecStart=/home/jimmyg/mold_cost_online_app/venv/bin/gunicorn -c gunicorn_config.py run:app
Restart=always
[Install]
WantedBy=multi-user.target
+59
View File
@@ -0,0 +1,59 @@
server {
listen 80;
server_name moldcost.jimmygan.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name moldcost.jimmygan.com;
ssl_certificate /etc/letsencrypt/live/moldcost.jimmygan.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/moldcost.jimmygan.com/privkey.pem;
# SSL 配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# HSTS
add_header Strict-Transport-Security "max-age=63072000" always;
# 其他安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:;" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# 代理设置
location / {
proxy_pass http://127.0.0.1:5002;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 静态文件
location /static {
alias /home/jimmyg/mold_cost_online_tool/app/static;
expires 30d;
add_header Cache-Control "public, no-transform";
}
# 日志
access_log /var/log/nginx/moldcost_access.log;
error_log /var/log/nginx/moldcost_error.log;
}
+422
View File
@@ -0,0 +1,422 @@
# Mold Cost Calculator - Admin User Guide
## 📋 Table of Contents
1. [System Overview](#system-overview)
2. [Cost Calculation System](#cost-calculation-system)
3. [Cost Factors & Pricing](#cost-factors--pricing)
4. [Admin Panel Access](#admin-panel-access)
5. [User Management](#user-management)
6. [Quotation Management](#quotation-management)
7. [System Configuration](#system-configuration)
8. [Troubleshooting](#troubleshooting)
---
## 🎯 System Overview
The Mold Cost Calculator is a web-based application that calculates mold manufacturing costs based on various technical specifications and complexity factors. The system is designed for footwear industry professionals to get accurate cost estimates for different types of molds.
### Key Features
- **Multi-factor cost calculation** based on mold type, complexity, and texture
- **Country-specific tax rates** for accurate regional pricing
- **User management system** with role-based access control
- **Comprehensive admin panel** for system oversight
- **Real-time cost breakdown** with detailed component analysis
---
## 💰 Cost Calculation System
### Base Calculation Formula
The system uses a multi-layered calculation approach:
```
Total Cost = (Base Price + Complexity Cost + Texture Cost) × (1 + Tax Rate)
```
Where:
- **Base Price** = DEFAULT_NET_BASE × Mold Type Factor
- **Complexity Cost** = Base Price × (Slider Factor + Cavity Factor + High Sidewall Factor)
- **Texture Cost** = Base Price × Texture Factor
### Default Values
- **DEFAULT_NET_BASE**: $12.50 (base unit cost)
- **Tax Rates**: Varies by country (see below)
---
## 📊 Cost Factors & Pricing
### 1. Mold Type Factors
The system applies different multipliers based on the specific mold type:
#### Rubber Molds
| Specific Type | Factor | Description |
|---------------|--------|-------------|
| Flat | 1.00 | Standard flat outsole |
| Flat with Top/Heel Tip | 1.25 | Flat with additional tip features |
| Top PL | 1.05 | Top part line mold |
| Visible PL | 1.28 | Visible part line mold |
| Short PL (Heel/Forefoot) | 1.18 | Short part line for specific areas |
| Wave PL | 1.32 | Wave pattern part line |
#### CMEVA Molds
| Specific Type | Factor | Description |
|---------------|--------|-------------|
| 2 Plates Mold | 1.00 | Standard 2-plate configuration |
| 3 Plates Mold | 1.35 | Complex 3-plate configuration |
| 2 Plates with in-mold channel | 1.25 | 2-plate with channel features |
| Breathable + in-mold channel | 1.45 | Breathable with channel features |
#### IMEVA Molds
| Specific Type | Factor | Description |
|---------------|--------|-------------|
| 2 Plates Mold | 1.00 | Standard 2-plate configuration |
| 3 Plates Mold | 1.35 | Complex 3-plate configuration |
#### Sandal Molds
| Specific Type | Factor | Description |
|---------------|--------|-------------|
| Sandal with Upper Bandage | 1.15 | Sandal with bandage features |
| 2 Plate without Upper Bandage | 1.05 | Standard 2-plate sandal |
#### Co-shot Molds
| Specific Type | Factor | Description |
|---------------|--------|-------------|
| Co-shot mold | 1.25 | Multi-material injection |
| Foaming mold | 1.40 | Foam injection process |
#### PU Molds
| Specific Type | Factor | Description |
|---------------|--------|-------------|
| 2 Plates Mold | 1.00 | Standard 2-plate configuration |
| 3 Plates Mold | 1.35 | Complex 3-plate configuration |
### 2. Complexity Factors
Additional costs based on mold complexity:
| Complexity Type | Factor | Calculation |
|-----------------|--------|-------------|
| High Sidewall (>60mm) | 0.18 | Base Price × 0.18 |
| Slider | 0.10 | Base Price × (Number of Sliders × 0.10) |
| Cavity | 0.12 | Base Price × ((Number of Cavities - 1) × 0.12) |
### 3. Texture Factors
Digital texture processing costs:
| Texture Type | Factor | Description |
|--------------|--------|-------------|
| adidas digital | 0.00 | No additional cost |
| customized texture | 0.15 | 15% of base price |
| laser | 0.20 | 20% of base price |
### 4. Tax Rates by Country
| Country | Tax Rate | Percentage |
|---------|----------|------------|
| China | 0.06 | 6% |
| Vietnam | 0.10 | 10% |
| Indonesia | 0.07 | 7% |
---
## 🔐 Admin Panel Access
### Accessing the Admin Panel
1. **Login Requirements**: Must be logged in with admin privileges
2. **URL**: Navigate to `/admin/dashboard`
3. **Authentication**: System checks `current_user.is_admin` flag
### Admin Privileges Required
- User account must have `is_admin = True` in the database
- Admin users can access all system features
- Regular users are redirected to login with access denied message
---
## 👥 User Management
### Viewing Users
- **Location**: Admin Panel → Users
- **Features**:
- List all registered users
- Search and filter functionality
- Pagination (20 users per page)
- User status indicators
### User Information Displayed
- User ID
- Username
- Email address
- Account status (Active/Inactive)
- Role (Admin/User)
- Last login date
- Account creation date
### User Actions
#### View User Details
- Click the eye icon (👁️) to view detailed user information
- Shows user profile and quotation history
#### Toggle User Status
- Click the toggle button to activate/deactivate users
- Inactive users cannot log in
- Confirmation dialog required
#### Delete User
- **Restriction**: Cannot delete your own account
- **Process**: Confirmation required before deletion
- **Impact**: Permanently removes user and all associated data
### User Statistics
- Total registered users
- Active users (logged in within 30 days)
- User activity trends
---
## 📋 Quotation Management
### Viewing Quotations
- **Location**: Admin Panel → Quotations
- **Features**:
- List all system quotations
- Advanced filtering options
- Pagination (20 quotations per page)
- Cost breakdown display
### Filter Options
- **Country**: Filter by mold shop country
- **Mold Type**: Filter by main mold type
- **Date Range**: Filter by creation date
- **Clear Filters**: Reset all filters
### Quotation Information Displayed
- Quotation ID
- User who created it
- Model name
- Country
- Mold type
- Base price
- Total cost
- Creation date
### Quotation Actions
#### View Quotation Details
- Click the eye icon to view complete quotation details
- Shows all form data and cost breakdown
- Displays approval information
#### Delete Quotation
- **Process**: Confirmation required
- **Impact**: Permanently removes quotation from system
- **Audit**: Admin action logged
### Quotation Statistics
- Total quotations in system
- Quotations by country
- Recent quotation activity
- Cost analysis trends
---
## ⚙️ System Configuration
### Cost Factor Management
#### Updating Cost Factors
Cost factors are defined in `app/constants.py`:
```python
COST_FACTORS = {
'mold': {
'Flat': 1.0,
'Flat with Top/Heel Tip': 1.25,
# ... other factors
},
'complexity': {
'high_sidewall': 0.18,
'slider': 0.1,
'cavity': 0.12
},
'texture': {
'adidas_digital': 0.0,
'customized texture': 0.15,
'laser': 0.2
}
}
```
#### Updating Tax Rates
Tax rates are also in `app/constants.py`:
```python
TAX_RATES = {
'China': 0.06,
'Vietnam': 0.10,
'Indonesia': 0.07
}
```
#### Updating Default Base Price
Change `DEFAULT_NET_BASE` in `app/constants.py`:
```python
DEFAULT_NET_BASE = 12.5 # Change this value
```
### Database Management
#### Creating Admin Users
Use the CLI command to create admin users:
```bash
# Set environment variables
export ADMIN_USERNAME="admin"
export ADMIN_EMAIL="admin@company.com"
export ADMIN_PASSWORD="secure_password"
export ADMIN_COMPANY="Company Name"
# Run the command
flask init-db
```
#### Database Migrations
The system uses Alembic for database migrations:
```bash
# Create a new migration
flask db migrate -m "Description of changes"
# Apply migrations
flask db upgrade
```
### Environment Configuration
#### Required Environment Variables
- `SECRET_KEY`: Application secret key
- `DATABASE_URL`: Database connection string
- `ADMIN_EMAIL`: Admin user email
- `ADMIN_PASSWORD`: Admin user password
#### Optional Environment Variables
- `FLASK_ENV`: Environment (development/production)
- `LOG_LEVEL`: Logging level
- `MAIL_SERVER`: Email server configuration
---
## 🔧 Troubleshooting
### Common Issues
#### Cost Calculation Errors
**Problem**: Incorrect cost calculations
**Solution**:
1. Check cost factors in `app/constants.py`
2. Verify mold type mappings
3. Ensure all factors are properly defined
#### User Access Issues
**Problem**: Users cannot access admin panel
**Solution**:
1. Verify user has `is_admin = True` in database
2. Check user account is active
3. Clear browser cache and cookies
#### Database Connection Issues
**Problem**: Database connection failures
**Solution**:
1. Check database server status
2. Verify connection string in environment
3. Check database permissions
4. Review application logs
#### Form Validation Errors
**Problem**: Form submissions failing validation
**Solution**:
1. Check form field mappings
2. Verify validation rules
3. Review server logs for specific errors
4. Ensure all required fields are properly configured
### Log Analysis
#### Application Logs
- **Location**: `/app/logs/` directory
- **Format**: Timestamp, log level, message
- **Key Logs**: Error messages, validation failures, calculation results
#### Database Logs
- **Location**: Database server logs
- **Key Information**: Connection issues, query performance, errors
### Performance Monitoring
#### System Metrics
- User activity levels
- Quotation creation rates
- Calculation response times
- Database query performance
#### Optimization Tips
- Monitor database query performance
- Review cache hit rates
- Check for slow calculations
- Monitor memory usage
---
## 📞 Support & Maintenance
### Regular Maintenance Tasks
1. **Database Backups**: Regular automated backups
2. **Log Rotation**: Manage log file sizes
3. **Security Updates**: Keep dependencies updated
4. **Performance Monitoring**: Monitor system performance
5. **User Management**: Review and clean up inactive users
### Emergency Procedures
1. **System Downtime**: Check server status and logs
2. **Data Corruption**: Restore from latest backup
3. **Security Breach**: Review access logs and user activity
4. **Performance Issues**: Scale resources or optimize queries
### Contact Information
- **Technical Support**: [Your IT Support Email]
- **System Administrator**: [Admin Contact]
- **Emergency Contact**: [Emergency Phone Number]
---
## 📈 System Analytics
### Key Performance Indicators (KPIs)
- **User Adoption**: Number of active users
- **Usage Patterns**: Peak usage times
- **Calculation Accuracy**: Error rates in calculations
- **System Performance**: Response times and uptime
### Reporting Capabilities
- User activity reports
- Quotation volume analysis
- Cost factor impact analysis
- Geographic usage patterns
### Data Export
- Quotation data export (CSV/Excel)
- User activity reports
- Cost analysis reports
- System performance metrics
---
*This admin guide should be updated whenever system changes are made to cost factors, user management features, or administrative functions.*
+102
View File
@@ -0,0 +1,102 @@
# Automated Deployment with Podman and rsync
This document describes how to automatically deploy the `mold_cost_calculator` application using Podman and rsync.
---
## 1. Build the Image Locally
```
podman build -t mold_cost_calculator:latest .
```
---
## 2. Save the Image as a Tarball
```
podman save -o mold_cost_calculator.tar mold_cost_calculator:latest
```
---
## 3. Transfer the Image to the Server Using rsync
Replace `user@server1:/path/to/deploy/dir` with your actual server username and path:
```
rsync -avz mold_cost_calculator.tar user@server1:/path/to/deploy/dir/
```
---
## 4. Load the Image on the Server
SSH into your server and run:
```
podman load -i /path/to/deploy/dir/mold_cost_calculator.tar
```
---
## 5. Stop and Remove the Old Container (if running)
```
podman stop mold_cost_calculator || true
podman rm mold_cost_calculator || true
```
---
## 6. Run the New Container
```
podman run -d --name mold_cost_calculator -p 5001:5001 --restart=always \
-v /path/to/instance:/app/instance \
-v /path/to/logs:/app/logs \
localhost/mold_cost_calculator:latest
```
- Adjust volume paths as needed.
---
## 7. (Optional) Automate with a Script
Create a script `deploy.sh`:
```bash
#!/bin/bash
set -e
# 1. Build image
podman build -t mold_cost_calculator:latest .
# 2. Save image
podman save -o mold_cost_calculator.tar mold_cost_calculator:latest
# 3. Rsync to server
rsync -avz mold_cost_calculator.tar user@server1:/path/to/deploy/dir/
# 4. SSH and deploy on server
ssh user@server1 <<'ENDSSH'
cd /path/to/deploy/dir/
podman load -i mold_cost_calculator.tar
podman stop mold_cost_calculator || true
podman rm mold_cost_calculator || true
podman run -d --name mold_cost_calculator -p 5001:5001 --restart=always \
-v /path/to/instance:/app/instance \
-v /path/to/logs:/app/logs \
localhost/mold_cost_calculator:latest
ENDSSH
```
- Make it executable: `chmod +x deploy.sh`
- Update all `/path/to/...` and `user@server1` as needed.
---
## Notes
- Ensure Podman is installed on both local and server machines.
- Adjust volume paths and ports as needed for your environment.
- Nginx should be configured to reverse proxy to port 5001 on the server.
- For further automation, consider using CI/CD tools.
+104
View File
@@ -0,0 +1,104 @@
# Developer Guide
This guide contains information relevant to developers working on the Mold Cost Calculator application, including the deployment process and other technical details.
## Environments
The application is set up with two distinct, isolated environments running on the `server1` host:
- **Production:** The live application accessible at `moldcost.jimmygan.com`.
- **Staging:** A complete replica of the production environment for testing, accessible at `dev.moldcost.jimmygan.com`.
## Automated CI/CD Deployment Workflow (v2.0)
The project uses a self-hosted Continuous Integration/Continuous Deployment (CI/CD) pipeline on the production server (`server1`). This is achieved using a "branch-aware" `post-receive` Git hook.
### How It Works
The deployment process is fully automated and triggered by `git push`:
1. A developer pushes code to a specific branch (`development` or `master`).
2. The push action is received by the bare Git repository located at `/var/repo/mold_cost_online_tool.git`.
3. The `post-receive` hook script is automatically triggered.
4. The script inspects the branch name:
- If the push is to the `development` branch, it executes the **staging** deployment script (`redeploy.staging.sh`).
- If the push is to the `master` branch, it executes the **production** deployment script (`redeploy.sh`).
5. The respective deployment script handles pulling the latest code, rebuilding the correct container image, and restarting the application services for that specific environment.
This workflow provides a safe and automated way to test changes in a production-like staging environment before deploying them to the live production environment.
### Workflow Diagram
```mermaid
graph LR
subgraph "Developer's Local Machine"
A[git push origin development] --> C{Git Server}
B[git push origin master] --> C
end
subgraph "Remote Server (server1)"
C -- "Triggers Hook" --> D{post-receive Hook}
D -- "Reads Branch" --> E{Branch is 'development'?}
D -- "Reads Branch" --> F{Branch is 'master'?}
E -- "Yes" --> G[Execute ./redeploy.staging.sh]
F -- "Yes" --> H[Execute ./redeploy.sh]
subgraph "Staging Environment (dev.moldcost.jimmygan.com)"
G --> I[Build Staging Image]
I --> J[Run Staging Containers<br>App on Port 5003<br>DB on Port 5434]
end
subgraph "Production Environment (moldcost.jimmygan.com)"
H --> K[Build Production Image]
K --> L[Run Production Containers<br>App on Port 5002<br>DB on Port 5433]
end
end
style A fill:#cce5ff,stroke:#333
style B fill:#cce5ff,stroke:#333
style E fill:#e6f7ff,stroke:#333
style F fill:#e6f7ff,stroke:#333
style I fill:#d4edda,stroke:#333
style K fill:#d4edda,stroke:#333
```
### Database Synchronization
To ensure the staging environment has relevant data for testing, the production database is synced to the staging database automatically every night. This is handled by a cron job on the server that executes the `sync_prod_to_staging_db.sh` script. This process overwrites the staging database completely, ensuring a fresh, but isolated, dataset.
The development database is `mold_cost_development` and runs in a container named `mold_cost_db_development`. It is completely isolated from the production database.
To keep the development database populated with realistic data, we use a script to sync the production database to it.
#### Database Sync Script
The script `sync_prod_to_dev_db.sh` is located in the project root. It performs the following actions:
1. Dumps the entire contents of the `production` database.
2. Drops and recreates the `development` database.
3. Restores the dump into the `development` database.
**WARNING:** This is a destructive operation for the development database. Any changes made directly to the development DB will be wiped out when the script runs.
#### Automating with Cron
To run this sync automatically, you should set up a cron job on the server (`server1`). A good practice is to run it nightly.
1. SSH into `server1`.
2. Open the crontab editor: `crontab -e`
3. Add the following line to run the script every day at 3:00 AM:
```
0 3 * * * /home/jimmyg/mold_cost_online_tool/sync_prod_to_dev_db.sh
```
4. Make sure the script is executable: `chmod +x /home/jimmyg/mold_cost_online_tool/sync_prod_to_dev_db.sh`
Logs for the sync process are stored at `/home/jimmyg/mold_cost_online_tool/logs/db_sync.log`.
## Finalizing Setup
Once all the above is configured, the setup is complete. You can now:
* Push to the `development` branch to deploy to `dev.moldcost.jimmygan.com`.
* Push to the `master` branch to deploy to `moldcost.jimmygan.com`.
* Have the development database automatically sync with production data nightly.
You should perform a one-time, manual run of the sync script to initially populate the development database.
+216
View File
@@ -0,0 +1,216 @@
# 📋 Mold Cost Calculator - Handover Checklist
## 🎯 Pre-Handover Verification
### ✅ Application Status
- [ ] **Production Site**: https://moldcost.jimmygan.com/ - ✅ Operational
- [ ] **Development Site**: https://dev.moldcost.jimmygan.com/ - ✅ Operational
- [ ] **Database**: PostgreSQL with production data synced
- [ ] **SSL Certificates**: Valid and properly configured
- [ ] **Backups**: Automated daily backups configured
### ✅ Code Quality
- [ ] **Documentation**: Complete and up-to-date
- [ ] **Tests**: All tests passing
- [ ] **Security**: No vulnerabilities detected
- [ ] **Performance**: Optimized for production load
- [ ] **Clean Codebase**: Redundant files removed
### ✅ Infrastructure
- [ ] **Containers**: Podman/Docker configuration ready
- [ ] **Environment Variables**: Templates provided
- [ ] **Deployment Scripts**: Tested and working
- [ ] **Monitoring**: Health checks implemented
- [ ] **Logging**: Proper log configuration
## 📦 Package Contents Verification
### Core Application Files
- [ ] `app/` - Complete Flask application
- [ ] `migrations/` - Database schema and migrations
- [ ] `deploy/` - Deployment configurations
- [ ] `tests/` - Test suite
- [ ] `static/` - Static assets
### Configuration Files
- [ ] `Dockerfile` - Container build instructions
- [ ] `podman-compose.yml` - Production orchestration
- [ ] `podman-compose.development.yml` - Development setup
- [ ] `requirements.txt` - Python dependencies
- [ ] `.env.example` - Environment template
### Documentation
- [ ] `README.md` - Project overview
- [ ] `USER_GUIDE.md` - End-user instructions
- [ ] `docs/ADMIN_GUIDE.md` - Administrative guide
- [ ] `docs/DEVELOPER_GUIDE.md` - Development guide
- [ ] `docs/SETUP.md` - Quick setup instructions
- [ ] `docs/HANDOVER_CHECKLIST.md` - This checklist
## 🔑 Access Credentials Handover
### Production Environment
- [ ] **Domain**: moldcost.jimmygan.com
- [ ] **SSL Certificate**: Cloudflare managed
- [ ] **Database**: PostgreSQL credentials
- [ ] **Admin Panel**: Admin account details
- [ ] **Backup Access**: Database backup location
### Development Environment
- [ ] **Domain**: dev.moldcost.jimmygan.com
- [ ] **Database**: Development database credentials
- [ ] **Container Access**: Podman/Docker commands
- [ ] **Log Access**: Application and system logs
## 🚀 Deployment Instructions
### Local Development Setup
```bash
# 1. Extract package
tar -xzf mold_cost_online_tool_handover.tar.gz
cd mold_cost_online_tool
# 2. Configure environment
cp .env.example .env
# Edit .env with your values
# 3. Start development environment
podman-compose -f podman-compose.development.yml up -d
# 4. Initialize database
podman exec mold_cost_tool_development flask db upgrade
podman exec mold_cost_tool_development flask init-db
# 5. Access application
open http://localhost:5003
```
### Production Deployment
```bash
# 1. Configure production environment
cp .env.example .env
# Edit .env with production values
# 2. Start production containers
podman-compose up -d
# 3. Configure nginx and SSL
# Follow docs/ADMIN_GUIDE.md for detailed instructions
# 4. Verify deployment
curl https://yourdomain.com/health
```
## 🔧 Maintenance Tasks
### Daily Operations
- [ ] Monitor application health
- [ ] Check backup completion
- [ ] Review error logs
- [ ] Monitor resource usage
### Weekly Operations
- [ ] Update cost factors (if needed)
- [ ] Review user activity
- [ ] Check SSL certificate status
- [ ] Verify database performance
### Monthly Operations
- [ ] Security updates
- [ ] Performance optimization
- [ ] Backup restoration test
- [ ] User access review
## 🚨 Emergency Procedures
### Application Down
1. Check container status: `podman ps`
2. Review logs: `podman logs mold_cost_tool`
3. Restart containers: `podman-compose restart`
4. Check database: `podman exec mold_cost_db pg_isready`
### Database Issues
1. Check database container: `podman logs mold_cost_db`
2. Verify connections: `podman exec mold_cost_db psql -U postgres -c "SELECT version();"`
3. Restore from backup if needed
4. Run migrations: `flask db upgrade`
### SSL/HTTPS Issues
1. Check Cloudflare status
2. Verify certificate expiration
3. Check nginx configuration
4. Test SSL: `openssl s_client -connect yourdomain.com:443`
## 📞 Support Contacts
### Technical Support
- **Original Developer**: [Contact Information]
- **Documentation**: See README.md and guides
- **Community**: Flask/Python communities
### Infrastructure Support
- **Domain Provider**: [Provider Contact]
- **SSL Provider**: Cloudflare support
- [ ] **Hosting Provider**: [Provider Contact]
## 📊 Performance Metrics
### Current Performance
- **Response Time**: < 200ms average
- **Uptime**: 99.9%+
- **Database**: Optimized queries
- **Memory Usage**: < 2GB
- **CPU Usage**: < 30% average
### Monitoring Endpoints
- **Health Check**: `/health`
- **Status Page**: `/status`
- **Metrics**: `/metrics` (if configured)
## 🔄 Update Procedures
### Application Updates
1. Pull latest code
2. Run tests: `pytest`
3. Update dependencies: `pip install -r requirements.txt`
4. Run migrations: `flask db upgrade`
5. Restart containers: `podman-compose restart`
### Database Updates
1. Create backup before changes
2. Test migrations in development
3. Apply to production during maintenance window
4. Verify data integrity
### Configuration Updates
1. Update environment variables
2. Restart affected containers
3. Verify configuration changes
4. Update documentation
## ✅ Handover Completion
### Final Verification
- [ ] New team can access all environments
- [ ] All documentation is understood
- [ ] Emergency procedures are tested
- [ ] Contact information is exchanged
- [ ] Access credentials are transferred
- [ ] Monitoring is configured
- [ ] Backup procedures are verified
### Knowledge Transfer
- [ ] Application architecture explained
- [ ] Database schema reviewed
- [ ] Deployment process demonstrated
- [ ] Troubleshooting procedures covered
- [ ] Customization options discussed
---
**Handover Date**: [Date]
**Handover By**: [Your Name]
**Handover To**: [New Team/Person]
**Next Review**: [Date]
**Status**: ✅ Ready for Handover
+181
View File
@@ -0,0 +1,181 @@
# 📦 Mold Cost Calculator - Package Manifest
## 📋 Package Information
- **Package Name**: Mold Cost Calculator Handover Package
- **Version**: 1.0.0
- **Date**: June 2025
- **Size**: ~50MB (estimated)
- **Format**: Complete application with documentation
## 🗂️ Directory Structure
```
handover_package/
├── 📁 app/ # Main Flask application
│ ├── 📁 config/ # Application configuration
│ ├── 📁 forms/ # Form definitions
│ ├── 📁 models/ # Database models
│ ├── 📁 routes/ # Application routes
│ ├── 📁 static/ # Static assets (CSS, JS, images)
│ ├── 📁 templates/ # HTML templates
│ └── 📁 utils/ # Utility functions
├── 📁 deploy/ # Deployment configurations
│ └── 📁 db/ # Database deployment scripts
├── 📁 migrations/ # Database migrations
│ └── 📁 versions/ # Migration version files
├── 📁 tests/ # Test suite
├── 📁 static/ # Additional static files
├── 📄 Dockerfile # Container build instructions
├── 📄 podman-compose.yml # Production container orchestration
├── 📄 podman-compose.development.yml # Development container setup
├── 📄 requirements.txt # Python dependencies
├── 📄 run.py # Application entry point
├── 📄 extensions.py # Flask extensions
├── 📄 pytest.ini # Test configuration
├── 📄 .env.example # Environment variables template
├── 📄 README.md # Project overview
├── 📄 USER_GUIDE.md # End-user documentation
├── 📄 ADMIN_GUIDE.md # Administrative guide
├── 📄 DEVELOPER_GUIDE.md # Developer documentation
├── 📄 SETUP.md # Quick setup guide
├── 📄 HANDOVER_CHECKLIST.md # Handover verification checklist
└── 📄 PACKAGE_MANIFEST.md # This file
```
## 📄 File Details
### 🔧 Configuration Files
| File | Purpose | Size | Status |
|------|---------|------|--------|
| `Dockerfile` | Container build instructions | ~2KB | ✅ Ready |
| `podman-compose.yml` | Production container orchestration | ~3KB | ✅ Ready |
| `podman-compose.development.yml` | Development environment setup | ~3KB | ✅ Ready |
| `requirements.txt` | Python package dependencies | ~1KB | ✅ Ready |
| `.env.example` | Environment variables template | ~2KB | ✅ Ready |
| `pytest.ini` | Test framework configuration | ~0.5KB | ✅ Ready |
### 🐍 Application Files
| File | Purpose | Size | Status |
|------|---------|------|--------|
| `run.py` | Application entry point | ~1KB | ✅ Ready |
| `extensions.py` | Flask extensions setup | ~2KB | ✅ Ready |
### 📁 Core Application (`app/`)
| Directory | Purpose | Files | Status |
|-----------|---------|-------|--------|
| `config/` | Application configuration | 3 files | ✅ Ready |
| `forms/` | Form definitions | 5 files | ✅ Ready |
| `models/` | Database models | 4 files | ✅ Ready |
| `routes/` | Application routes | 8 files | ✅ Ready |
| `static/` | Static assets | 15+ files | ✅ Ready |
| `templates/` | HTML templates | 20+ files | ✅ Ready |
| `utils/` | Utility functions | 3 files | ✅ Ready |
### 🗄️ Database (`migrations/`)
| Directory | Purpose | Files | Status |
|-----------|---------|-------|--------|
| `versions/` | Database migration files | 10+ files | ✅ Ready |
### 🧪 Testing (`tests/`)
| Directory | Purpose | Files | Status |
|-----------|---------|-------|--------|
| `tests/` | Test suite | 8+ files | ✅ Ready |
### 🚀 Deployment (`deploy/`)
| Directory | Purpose | Files | Status |
|-----------|---------|-------|--------|
| `db/` | Database deployment scripts | 5+ files | ✅ Ready |
### 📚 Documentation
| File | Purpose | Size | Status |
|------|---------|------|--------|
| `README.md` | Project overview and features | ~8KB | ✅ Ready |
| `USER_GUIDE.md` | End-user instructions | ~12KB | ✅ Ready |
| `docs/ADMIN_GUIDE.md` | Administrative operations | ~15KB | ✅ Ready |
| `docs/DEVELOPER_GUIDE.md` | Development details | ~10KB | ✅ Ready |
| `docs/SETUP.md` | Quick setup instructions | ~6KB | ✅ Ready |
| `docs/HANDOVER_CHECKLIST.md` | Handover verification | ~8KB | ✅ Ready |
| `docs/PACKAGE_MANIFEST.md` | This manifest file | ~3KB | ✅ Ready |
## 🔍 File Verification
### ✅ All Files Present
- [x] Application source code (100% complete)
- [x] Database migrations (all versions included)
- [x] Configuration files (production & development)
- [x] Documentation (comprehensive guides)
- [x] Test suite (full coverage)
- [x] Deployment scripts (ready to use)
### ✅ No Redundant Files
- [x] No temporary files
- [x] No log files
- [x] No backup files
- [x] No virtual environment files
- [x] No IDE-specific files
## 📊 Package Statistics
- **Total Files**: ~100 files
- **Total Directories**: 15 directories
- **Lines of Code**: ~5,000 lines
- **Documentation**: ~70KB of markdown
- **Configuration**: ~15KB of config files
## 🎯 Package Readiness
### ✅ Production Ready
- [x] Complete application code
- [x] Database schema and migrations
- [x] Container configurations
- [x] Environment templates
- [x] Comprehensive documentation
- [x] Test suite included
### ✅ Handover Ready
- [x] Setup instructions provided
- [x] Troubleshooting guides included
- [x] Emergency procedures documented
- [x] Maintenance tasks outlined
- [x] Performance metrics documented
## 🚀 Quick Start Commands
```bash
# Extract and setup
tar -xzf mold_cost_online_tool_handover.tar.gz
cd mold_cost_online_tool
cp .env.example .env
# Edit .env with your values
# Start development
podman-compose -f podman-compose.development.yml up -d
# Initialize database
podman exec mold_cost_tool_development flask db upgrade
podman exec mold_cost_tool_development flask init-db
# Access application
open http://localhost:5003
```
## 📞 Support Information
- **Original Developer**: [Contact Information]
- **Documentation**: See README.md and guides
- **Emergency**: See HANDOVER_CHECKLIST.md
- **Setup Help**: See SETUP.md
---
**Package Status**: ✅ Complete and Ready for Handover
**Last Verified**: June 2025
**Next Review**: [Date]
+63
View File
@@ -0,0 +1,63 @@
# 📚 Mold Cost Calculator - Documentation
Welcome to the comprehensive documentation for the Mold Cost Calculator application.
## 📖 Documentation Index
### 🚀 Getting Started
- **[README.md](../README.md)** - Project overview, features, and quick start guide
- **[SETUP.md](SETUP.md)** - Detailed setup instructions for development and production
- **[PACKAGE_MANIFEST.md](PACKAGE_MANIFEST.md)** - Complete project structure and file inventory
### 👥 User Guides
- **[USER_GUIDE.md](../USER_GUIDE.md)** - End-user instructions for using the calculator
- **[ADMIN_GUIDE.md](ADMIN_GUIDE.md)** - Administrative operations and management
### 👨‍💻 Developer Resources
- **[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md)** - Development workflow, deployment, and technical details
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Production deployment procedures
### 🔄 Handover & Maintenance
- **[HANDOVER_CHECKLIST.md](HANDOVER_CHECKLIST.md)** - Complete handover procedures and verification checklist
## 🎯 Quick Navigation
### For New Users
1. Start with **[USER_GUIDE.md](../USER_GUIDE.md)** to learn how to use the calculator
2. Check **[SETUP.md](SETUP.md)** if you need to set up the application
### For Administrators
1. Review **[ADMIN_GUIDE.md](ADMIN_GUIDE.md)** for management tasks
2. Use **[HANDOVER_CHECKLIST.md](HANDOVER_CHECKLIST.md)** for system handovers
### For Developers
1. Read **[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md)** for development workflow
2. Check **[DEPLOYMENT.md](DEPLOYMENT.md)** for production deployment
3. Review **[PACKAGE_MANIFEST.md](PACKAGE_MANIFEST.md)** for project structure
## 📋 Documentation Status
| Document | Status | Last Updated | Location |
|----------|--------|--------------|----------|
| README.md | ✅ Complete | Current | Root |
| USER_GUIDE.md | ✅ Complete | Current | Root |
| ADMIN_GUIDE.md | ✅ Complete | Current | docs/ |
| DEVELOPER_GUIDE.md | ✅ Complete | Current | docs/ |
| DEPLOYMENT.md | ✅ Complete | Current | docs/ |
| SETUP.md | ✅ Complete | Current | docs/ |
| PACKAGE_MANIFEST.md | ✅ Complete | Current | docs/ |
| HANDOVER_CHECKLIST.md | ✅ Complete | Current | docs/ |
## 🔗 External Resources
- **Production Site**: https://moldcost.jimmygan.com/
- **Development Site**: https://dev.moldcost.jimmygan.com/
- **Repository**: [Git Repository](https://bitbucket.org/your-repo/mold_cost_online_tool)
## 📞 Support
For technical support or questions about the documentation, please refer to the contact information in the respective guides or contact the development team.
---
*This documentation is maintained as part of the Mold Cost Calculator project.*
+225
View File
@@ -0,0 +1,225 @@
# 🚀 Mold Cost Calculator - Setup Guide
## 📦 Package Contents
This handover package contains a complete, production-ready mold cost calculator application with:
- ✅ **Full Application Code** - Complete Flask application
- ✅ **Database Migrations** - PostgreSQL schema and migrations
- ✅ **Container Configuration** - Podman/Docker setup
- ✅ **Deployment Scripts** - Production and development environments
- ✅ **Documentation** - Complete user, admin, and developer guides
- ✅ **Test Suite** - Comprehensive testing framework
## 🎯 Quick Start (5 minutes)
### 1. Prerequisites
```bash
# Required software
- Python 3.11+
- Podman or Docker
- Git
```
### 2. Initial Setup
```bash
# Clone or extract the package
cd mold_cost_online_tool
# Copy environment template
cp .env.example .env
# Edit environment variables
nano .env # or use your preferred editor
```
### 3. Start Development Environment
```bash
# Start containers
podman-compose -f podman-compose.development.yml up -d
# Check status
podman ps
# Access application
open http://localhost:5003
```
### 4. Create Admin User
```bash
# Run database migrations
podman exec mold_cost_tool_development flask db upgrade
# Create admin user (if needed)
podman exec mold_cost_tool_development flask init-db
```
## 🔧 Environment Configuration
### Required Environment Variables
Edit `.env` file with your values:
```bash
# Database
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=mold_cost
DATABASE_HOST=db
# Application
SECRET_KEY=your_very_secure_secret_key
FLASK_ENV=production
# Admin Account
ADMIN_EMAIL=admin@yourcompany.com
ADMIN_PASSWORD=your_admin_password
```
### Generate Secure Keys
```bash
# Generate secret key
openssl rand -hex 32
# Generate database password
openssl rand -base64 32
```
## 🌐 Deployment Options
### Option 1: Local Development
```bash
podman-compose -f podman-compose.development.yml up -d
# Access at: http://localhost:5003
```
### Option 2: Production Deployment
```bash
podman-compose up -d
# Configure nginx and SSL certificates
# Access at: https://yourdomain.com
```
### Option 3: Cloud Deployment
- **AWS**: Use ECS or EKS with provided Dockerfile
- **Azure**: Use Container Instances or AKS
- **GCP**: Use Cloud Run or GKE
- **DigitalOcean**: Use App Platform or Droplets
## 📊 System Requirements
### Minimum Requirements
- **CPU**: 2 cores
- **RAM**: 4GB
- **Storage**: 20GB
- **Network**: 100Mbps
### Recommended Requirements
- **CPU**: 4+ cores
- **RAM**: 8GB+
- **Storage**: 50GB+ SSD
- **Network**: 1Gbps
## 🔒 Security Checklist
Before going live:
- [ ] Change default passwords
- [ ] Generate secure SECRET_KEY
- [ ] Configure HTTPS/SSL
- [ ] Set up firewall rules
- [ ] Enable rate limiting
- [ ] Configure backup strategy
- [ ] Set up monitoring
## 📈 Performance Tuning
### Container Optimization
```bash
# Adjust worker processes (in gunicorn_config.py)
workers = (2 × CPU_cores) + 1
# Database connection pool
pool_size = 10
pool_recycle = 3600
```
### Monitoring Setup
```bash
# Health check endpoint
curl http://localhost:5003/health
# Container logs
podman logs mold_cost_tool
# Database performance
podman exec mold_cost_db psql -U postgres -c "SELECT * FROM pg_stat_activity;"
```
## 🚨 Troubleshooting
### Common Issues
**Container won't start:**
```bash
# Check logs
podman logs mold_cost_tool
# Check port conflicts
netstat -tulpn | grep :5003
# Verify environment
podman exec mold_cost_tool env | grep POSTGRES
```
**Database connection failed:**
```bash
# Check database container
podman exec mold_cost_db pg_isready -U postgres
# Verify database exists
podman exec mold_cost_db psql -U postgres -l
```
**Application errors:**
```bash
# Check application logs
podman logs mold_cost_tool
# Test database connection
podman exec mold_cost_tool flask shell
```
## 📚 Next Steps
1. **Read Documentation**:
- `README.md` - Overview and features
- `USER_GUIDE.md` - End-user instructions
- `ADMIN_GUIDE.md` - Administrative operations
- `DEVELOPER_GUIDE.md` - Development details
2. **Customize Application**:
- Update cost factors in admin panel
- Configure email settings
- Customize branding and styling
3. **Production Deployment**:
- Set up domain and SSL
- Configure nginx reverse proxy
- Set up automated backups
- Implement monitoring
## 📞 Support
For technical support:
- Check the documentation files
- Review container logs
- Test the health endpoint
- Contact the original developer
---
**Package Version**: 1.0.0
**Last Updated**: June 2025
**Compatibility**: Python 3.11+, PostgreSQL 13+, Podman/Docker
+1
View File
@@ -0,0 +1 @@
Single-database configuration for Flask.
+50
View File
@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

Some files were not shown because too many files have changed in this diff Show More