Commit 926e390d authored by wanli's avatar wanli

feat(应用管理模块应用列表): 完善应用列表

修复前端登录几处bug
parent 796e9b1b
......@@ -14,10 +14,25 @@ from models.annex import AnnexModel
from models.user import UserModel
from models.app import AppModel
from application.config import config
from webcreator import utils
from webcreator.utils.epk import EpkApp
from webcreator.log import logger
from webcreator.response import ResponseCode
@utils.ThreadMaker
def update_information(ip, record_id):
try:
jsonData = utils.get_location_by_ip(ip)
if (0 != jsonData['status']):
return None
pack = PackageModel.query.filter(PackageModel.id==record_id).first()
pack.geo_location = json.dumps(jsonData, ensure_ascii=False)
pack.ip = ip
db.session.commit()
except Exception as e:
logger.error(e)
class AppResource(object):
def __init__(self):
super().__init__()
......@@ -69,7 +84,7 @@ class AppResource(object):
# 打包成EPK文件
app_info = {}
params = { 'appName': app.app_name, 'appDir': dest_dir, 'appVersion': app.app_version, 'output': target_dir }
if user.role == "administrator" or user.role == "community":
if user.role == 1:
params['algorithm'] = "h"
epk = EpkApp(**params)
app_info = epk.pack()
......@@ -94,24 +109,18 @@ class AppResource(object):
return { 'app_name': app.app_name, 'app_path': epk_path }, ResponseCode.HTTP_NOT_FOUND
def getList(self, params):
# handle business
logger.warn(params)
filters = [AppModel.is_delete==False]
result = AppModel.query.filter(*filters).order_by(AppModel.create_at).paginate(params.get('page', 1), params.get('pageSize', 10), error_out=False)
if not result:
return None, ResponseCode.HTTP_NOT_FOUND
user = UserModel.query.filter(UserModel.id==params.get('user')).one_or_none()
def getList(self, params, jwt):
user = UserModel.query.filter(UserModel.uuid==jwt.get('uuid')).one_or_none()
if not user:
return False, ResponseCode.USER_NOT_EXISTS
temp = {}
if user.role == "administrator":
temp.update({"is_delete": False})
filters = [AppModel.is_delete==False]
if user.role == 1:
temp.update({ "is_delete": False })
else:
temp.update({ "create_by": user, "is_delete": False })
filters.append(AppModel.create_by==user.id)
temp.update({ "create_by": user.id, "is_delete": False })
if "scope_type" in params and params.get("scope_type") == "list":
result = AppModel.query.filter_by(**temp).order_by(AppModel.create_at.desc())
......@@ -126,22 +135,19 @@ class AppResource(object):
temp.append(item)
return (temp, len(temp)), ResponseCode.HTTP_SUCCESS
result = AppModel.query.filter_by(**temp).order_by(AppModel.sort).order_by(AppModel.create_at.desc()).paginate(params.get("pagenum", 1), params.get("pagesize", 10), error_out=False)
for p in params:
if hasattr(AppModel, p) and params[p] != None:
temp[p] = params[p]
logger.info(temp)
result = AppModel.query.filter_by(**temp).order_by(AppModel.create_at.desc()).paginate(params.get("page", 1), params.get("pageSize", 15), error_out=False)
# result = AppModel.query.filter(*filters).order_by(AppModel.create_at.desc()).paginate(params.get('page', 1), params.get('pageSize', 15), error_out=False)
if result.total and len(result.items):
temp = []
for item in result.items:
t = dict()
t.update(item.to_json())
t.update({
"create_by": item.create_by.to_dict(only=["uuid", "username"]),
"update_by": item.update_by.to_dict(only=["uuid", "username"]),
"create_at": item.create_at.strftime("%Y-%m-%d %H:%M:%S") if item.create_at else None,
"update_at": item.update_at.strftime("%Y-%m-%d %H:%M:%S") if item.update_at else None,
})
temp.append(t)
result = temp
return result, ResponseCode.HTTP_SUCCESS
return result, ResponseCode.HTTP_SUCCESS
return None, ResponseCode.HTTP_NOT_FOUND
def post(self, params, jwt={}):
# handle business
......@@ -178,6 +184,9 @@ class AppResource(object):
params.pop("epk_path")
if params.get("logo"):
params.pop("logo")
real_ip = params.get("real_ip", None)
if real_ip:
params.pop("real_ip")
app = AppModel(**params)
db.session.add(app)
......@@ -190,14 +199,15 @@ class AppResource(object):
logger.info(app_files)
for a in app_files:
res = AnnexModel(app=app.id, title=os.path.basename(a), path=a, size=os.path.getsize(a), create_by=user.id, create_at=datetime.now(), update_by=user.id, update_at=datetime.now())
t = Path(config.UPLOAD_ROOT_DIR).joinpath(a)
res = AnnexModel(app=app.id, title=t.name, path=a, size=t.stat().st_size, create_by=user.id, create_at=datetime.now(), update_by=user.id, update_at=datetime.now())
db.session.add(res)
db.session.flush()
db.session.commit()
app_info = {}
params = { 'appName': app.app_name, 'appDir': epk_path.resolve().as_posix(), 'appVersion': app.app_version, 'output': epk_path.parent.resolve().as_posix() }
if user.role == "administrator" or user.role == "community":
if user.role == 1:
params['algorithm'] ="h"
if algorithm:
......@@ -210,26 +220,17 @@ class AppResource(object):
if app_info:
app_info['md5'] = str(app_info['md5'])
app.app_file_size = app_info.get("buff_length")
app.download_url = epk_filename
db.session.commit()
package = PackageModel(app=app.id, file_path=epk_filename, app_version=params.get("appVersion"), package_info=json.dumps(app_info), source=1, create_by=user.id, create_at=datetime.now(), update_by=user.id, update_at=datetime.now(), remarks=json.dumps(params))
db.session.add(package)
db.session.commit()
return True, ResponseCode.HTTP_SUCCESS
update_information(real_ip, package.id)
# result = AppModel.query.filter(AppModel.app_name == params.get('app_name')).first()
# if result and result.is_delete:
# result.is_delete = False
# result.update_by = jwt.get("id", "")
# result.update_date = datetime.now()
# db.session.commit()
# return True, ResponseCode.HTTP_SUCCESS
# elif result and result.is_delete == False:
# return False, ResponseCode.HTTP_INVAILD_REQUEST
# result = AppModel(**params)
# db.session.add(result)
# db.session.commit()
# return True, ResponseCode.HTTP_SUCCESS
return True, ResponseCode.HTTP_SUCCESS
def put(self, uuid, params, jwt={}):
user = UserModel.query.filter(UserModel.id==params.get('user')).one_or_none()
......
......@@ -15,8 +15,6 @@ from webcreator.response import ResponseCode
def update_login_information(ip, log_id):
try:
jsonData = utils.get_location_by_ip(ip)
logger.info(jsonData)
if (0 != jsonData['status']):
return None
......
......@@ -57,6 +57,10 @@ class AppModel(PrimaryModel):
'app_screen_size': self.app_screen_size,
'app_arch': self.app_arch,
'app_review': self.app_review,
"create_by": self.create_by,
"update_by": self.update_by,
"create_at": self.create_at.strftime("%Y-%m-%d %H:%M:%S") if self.create_at else None,
"update_at": self.update_at.strftime("%Y-%m-%d %H:%M:%S") if self.update_at else None,
}
......@@ -95,13 +99,23 @@ class GetListAppSchema(ma.SQLAlchemySchema):
unknown = EXCLUDE # 未知字段默认排除
model = AppModel
page = fields.Integer(required=False)
pageSize = fields.Integer(required=False)
app_name = ma.auto_field()
app_version = ma.auto_field()
category = ma.auto_field()
category_2th = ma.auto_field()
app_arch = ma.auto_field()
id = fields.Integer(required=False, nullable=True)
uuid = fields.String(required=False, nullable=True)
page = fields.Integer(required=False, default=1, nullable=True)
pageSize = fields.Integer(required=False, default=15, nullable=True)
app_name = fields.String(required=False, nullable=True)
app_icon = fields.String(required=False, nullable=True)
app_version = fields.String(required=False, nullable=True)
category = fields.String(required=False, nullable=True)
category_2t = fields.String(required=False, nullable=True)
app_arch = fields.String(required=False, nullable=True)
download_url = fields.String(required=False, nullable=True)
app_file_size = fields.Integer(required=False, nullable=True)
app_screen_size = fields.String(required=False, nullable=True)
app_review = fields.Integer(required=False, nullable=True)
create_at = fields.DateTime(required=False, nullable=True)
update_at = fields.DateTime(required=False, nullable=True)
getListAppSchema = GetListAppSchema()
getListAppsSchema = GetListAppSchema(many=True)
......
'''
Author: your name
Date: 2021-07-15 09:33:39
LastEditTime: 2021-07-16 11:48:39
LastEditors: Please set LastEditors
Description: In User Settings Edit
FilePath: \evm-store\tools\build_out\models\base.py
'''
#!/usr/bin/env python
# -*- coding: utf_8 -*-
# Documention: https://docs.sqlalchemy.org/en/14/orm/declarative_mixins.html
import uuid
from datetime import datetime
from application.app import db
from sqlalchemy import ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.declarative import declared_attr
def generate_uuid():
return uuid.uuid1().hex
......@@ -13,11 +27,21 @@ class BaseModel(db.Model):
# 方法就是把__abstract__这个属性设置为True,这个类为基类,不会被创建为表!
__abstract__ = True
create_at = db.Column(db.DateTime, default=datetime.now)
create_by = db.Column(db.String(64))
update_at = db.Column(db.DateTime, default=datetime.now)
update_by = db.Column(db.String(64))
remarks = db.Column(db.String(255), default="")
is_delete = db.Column(db.BOOLEAN, default=0)
is_delete = db.Column(db.Boolean, default=0)
@declared_attr
def create_by(cls):
return db.Column(db.Integer, ForeignKey("evm_user.id"))
@declared_attr
def update_by(cls):
return db.Column(db.Integer, ForeignKey("evm_user.id"))
# __table_args__ = (UniqueConstraint('machine_id', ts, value),
# Index('system_machine_id_index', 'machine_id'),
# Index('system_ts_index', ts))
class PrimaryModel(BaseModel):
__abstract__ = True
......
......@@ -19,24 +19,33 @@ class AppResourceList(Resource):
# 特殊参数,即不是从json获取参数的接口,可以将这个注释打开
self.parser = RequestParser()
@jwt_required(locations=["headers"])
def get(self):
# 特殊参数,即不是从json获取参数的接口,可以将这个注释打开
# self.parser.add_argument("page", type=int, location="args", default=1)
# self.parser.add_argument("pageSize", type=int, location="args", default=15)
# args = self.parser.parse_args()
self.parser.add_argument("app_name", type=str, location="args", nullable=True, required=False)
self.parser.add_argument("app_version", type=str, location="args", nullable=True, required=False)
self.parser.add_argument("category", type=str, location="args", nullable=True, required=False)
self.parser.add_argument("app_screen_size", type=str, location="args", nullable=True, required=False)
self.parser.add_argument("app_arch", type=str, location="args", nullable=True, required=False)
self.parser.add_argument("page", type=int, location="args", default=1)
self.parser.add_argument("pageSize", type=int, location="args", default=15)
args = self.parser.parse_args()
try:
json_payload = request.json
logger.warn(json_payload)
data = getListAppSchema.load(json_payload)
result = signalManager.actionGetListApp.emit(data)
jwt = get_jwt_identity()
# data = getListAppSchema.load(args)
data = dict()
for key, value in args.items():
if value != None:
data[key] = value
result, message = signalManager.actionGetListApp.emit(data, jwt)
json_dumps = getListAppSchema.dump(result)
if result:
json_dumps = getListAppsSchema.dump(result.items)
logger.warn(json_dumps)
return response_result(ResponseCode.HTTP_SUCCESS, data=json_dumps, count=result.total)
return response_result(ResponseCode.HTTP_NOT_FOUND)
return response_result(ResponseCode.HTTP_SUCCESS, data=json_dumps, total=result.total, pageSize=args.pageSize)
return response_result(message)
except Exception as e:
traceback.print_exc()
current_app.logger.error(e)
return response_result(ResponseCode.HTTP_SERVER_ERROR)
......@@ -67,9 +76,7 @@ class AppResourceList(Resource):
logger.info(dirname)
# 获取最终存储的绝对路径
upload_path = Path(config.EPK_DIR).joinpath(dirname)
# upload_path = os.path.normpath(os.sep.join([config.UPLOAD_ROOT_DIR, relative_path]))
logger.info(upload_path)
if not upload_path.exists():
os.makedirs(upload_path.resolve().as_posix())
......@@ -78,35 +85,24 @@ class AppResourceList(Resource):
logo = request.files.get("logo") # args.get('picture')
if logo:
filename = secure_filename(logo.filename)
logger.info(filename)
# file_path = os.sep.join([upload_path, filename])
file_path = upload_path.joinpath(filename)
logo.save(file_path)
logger.info(file_path.resolve().as_posix())
params.update({ "app_icon": file_path.resolve().as_posix() })
params.update({ "app_icon": file_path.relative_to(relative_path).resolve().as_posix() })
# 应用源文件
fileList = request.files.getlist('fileList') # args.get('picture')
fileList = request.files.getlist('fileList')
if fileList:
# upload_path = os.sep.join([upload_path, "src"])
upload_path = upload_path.joinpath("src")
if not upload_path.exists():
logger.info(upload_path.resolve().as_posix())
os.mkdir(upload_path.resolve().as_posix())
for f in fileList:
filename = secure_filename(f.filename)
# file_path = os.sep.join([upload_path, filename])
# file_path = os.path.normpath(file_path)
file_path = upload_path.joinpath(filename)
f.save(file_path.resolve().as_posix())
files.append(file_path.resolve().as_posix())
# obj = dict()
# obj['filename'] = binfile.filename
# obj['content'] = binfile.stream.read()
files.append(file_path.relative_to(relative_path).resolve().as_posix())
params.update({ "fileList": files, "epk_path": upload_path })
params.update({ "fileList": files, "epk_path": upload_path, 'real_ip': request.headers.get('X-Forwarded-For', '127.0.0.1') })
result, message = signalManager.actionPostApp.emit(params, jwt)
if result:
logger.warn(result)
......
/*
* @Author: your name
* @Date: 2021-07-15 09:33:39
* @LastEditTime: 2021-07-15 19:22:09
* @LastEditTime: 2021-07-16 10:00:41
* @LastEditors: Please set LastEditors
* @Description: In User Settings Edit
* @FilePath: \evm-store\tools\frontend\src\api\openapi.js
......@@ -30,4 +30,27 @@ export function postApplication(params) {
method: "post",
data: params
})
}
\ No newline at end of file
}
export function getApplicationList(params) {
return request({
url: "/api/v1/app",
method: "get",
params
})
}
export function rebuildApplication(uuid, params) {
return request({
url: `/api/v1/app/${uuid}`,
method: "get",
params
})
}
export function deleteApplication(uuid) {
return request({
url: `/api/v1/app/${uuid}`,
method: "delete"
})
}
/*
* @Author: your name
* @Date: 2021-07-15 09:33:39
* @LastEditTime: 2021-07-15 21:14:30
* @LastEditTime: 2021-07-16 14:21:19
* @LastEditors: Please set LastEditors
* @Description: In User Settings Edit
* @FilePath: \evm-store\tools\frontend\src\store\modules\frontend-login.js
......@@ -43,7 +43,6 @@ const actions = {
},
removeToken({ commit }) {
commit("removeToken");
resetRouter();
},
setUserInfo({ commit }, userinfo) {
commit("setUserInfo", userinfo);
......
/**
* This is just a simple version of deep copy
* Has a lot of edge cases bug
* If you want to use a perfect deep copy, use lodash's _.cloneDeep
* @param {Object} source
* @returns {Object}
*/
export function deepClone(source) {
if (!source && typeof source !== "object") {
throw new Error("error arguments", "deepClone");
}
const targetObj = source.constructor === Array ? [] : {};
Object.keys(source).forEach((keys) => {
if (source[keys] && typeof source[keys] === "object") {
targetObj[keys] = deepClone(source[keys]);
} else {
targetObj[keys] = source[keys];
}
});
return targetObj;
}
/**
* Parse the time to string
* @param {(Object|string|number)} time
* @param {string} cFormat
* @returns {string | null}
*/
export function parseTime(time, cFormat) {
if (arguments.length === 0) {
return null;
}
const format = cFormat || "{y}-{m}-{d} {h}:{i}:{s}";
let date;
if (typeof time === "object") {
date = time;
} else {
if (typeof time === "string" && /^[0-9]+$/.test(time)) {
time = parseInt(time);
}
if (typeof time === "number" && time.toString().length === 10) {
time = time * 1000;
}
date = new Date(time);
}
const formatObj = {
y: date.getFullYear(),
m: date.getMonth() + 1,
d: date.getDate(),
h: date.getHours(),
i: date.getMinutes(),
s: date.getSeconds(),
a: date.getDay(),
};
const time_str = format.replace(/{([ymdhisa])+}/g, (result, key) => {
const value = formatObj[key];
// Note: getDay() returns 0 on Sunday
if (key === "a") {
return ["", "", "", "", "", "", ""][value];
}
return value.toString().padStart(2, "0");
});
return time_str;
}
/**
* @param {number} time
* @param {string} option
* @returns {string}
*/
export function formatTime(time, option) {
if (("" + time).length === 10) {
time = parseInt(time) * 1000;
} else {
time = +time;
}
const d = new Date(time);
const now = Date.now();
const diff = (now - d) / 1000;
if (diff < 30) {
return "刚刚";
} else if (diff < 3600) {
// less 1 hour
return Math.ceil(diff / 60) + "分钟前";
} else if (diff < 3600 * 24) {
return Math.ceil(diff / 3600) + "小时前";
} else if (diff < 3600 * 24 * 2) {
return "1天前";
}
if (option) {
return parseTime(time, option);
} else {
return (
d.getMonth() +
1 +
"" +
d.getDate() +
"" +
d.getHours() +
"" +
d.getMinutes() +
""
);
}
}
/**
* @param {string} url
* @returns {Object}
*/
export function param2Obj(url) {
const search = url.split("?")[1];
if (!search) {
return {};
}
return JSON.parse(
'{"' +
decodeURIComponent(search)
.replace(/"/g, '\\"')
.replace(/&/g, '","')
.replace(/=/g, '":"')
.replace(/\+/g, " ") +
'"}'
);
}
/**
* @param {Function} func
* @param {number} wait
* @param {boolean} immediate
* @return {*}
*/
export function debounce(func, wait, immediate) {
let timeout, args, context, timestamp, result;
const later = function () {
// 据上一次触发时间间隔
const last = +new Date() - timestamp;
// 上次被包装函数被调用时间间隔 last 小于设定时间间隔 wait
if (last < wait && last > 0) {
timeout = setTimeout(later, wait - last);
} else {
timeout = null;
// 如果设定为immediate===true,因为开始边界已经调用过了此处无需调用
if (!immediate) {
result = func.apply(context, args);
if (!timeout) context = args = null;
}
}
};
return function (...args) {
context = this;
timestamp = +new Date();
const callNow = immediate && !timeout;
// 如果延时不存在,重新设定延时
if (!timeout) timeout = setTimeout(later, wait);
if (callNow) {
result = func.apply(context, args);
context = args = null;
}
return result;
};
}
export function value2CheckedList(value, options, length) {
if (value === undefined) {
return;
}
var bits = value.toString(2);
var bitsCount = bits.length;
for (var i = 0; i < length - bitsCount; i++) {
bits = "0" + bits;
}
bits = bits.split("").reverse().join("");
var ret = [];
for (i = 0; i < bits.length; i++) {
if (bits[i] === "1") {
ret.push(options[i]);
}
}
return ret;
}
export function checkedList2Value(checkedList, options, length) {
var bits = "";
for (var i = 0; i < length; i++) {
if (checkedList.indexOf(options[i]) != -1) {
bits += "1";
} else {
bits += "0";
}
}
bits = bits.split("").reverse().join("");
return parseInt(bits, 2);
}
export function setCache(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
export function getCache(key) {
return JSON.parse(localStorage.getItem(key));
}
export function mapTrim(obj) {
const t = {};
Object.keys(obj).forEach((key) => {
if (
typeof obj[key] !== "undefined" &&
obj[key] !== null &&
obj[key] !== ""
) {
if (typeof obj[key] === "string") t[key] = obj[key].trim();
else t[key] = obj[key];
}
});
return t;
}
export function strTrim(str) {
str = str
.replace(/^([\s\n\r]|<br>|<br\/>|&nbsp;)+/, "")
.replace(/([\s\n\r]|<br>|<br\/>|&nbsp;)+$/, "");
return str.replace(/(\r\n)|[\n\r]/g, "<br/>"); // 转换换行符
}
export function getUUID() {
let s = [];
const hexDigits = "0123456789abcdef";
for (let i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = "4"; // bits 12-15 of the time_hi_and_version field to 0010
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1); // bits 6-7 of the clock_seq_hi_and_reserved to 01
s[8] = s[13] = s[18] = s[23] = "-";
return s.join("");
}
export function compareObjectDiff(dest, sour) {
const result = {};
Object.keys(dest).filter((key) => {
if (dest[key] != sour[key]) result[key] = dest[key];
});
return result;
}
export function formatBytes(bytes, decimals) {
if (bytes == 0) return "0 Bytes";
var k = 1024,
dm = decimals + 1 || 3,
sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
}
export function download(name, url) {
return new Promise((resolve) => {
var a = document.createElement("a");
a.href = url;
a.download = name;
a.target = "_blank";
a.click();
resolve();
// document.body.removeChild(a);
});
}
/*
* @Author: your name
* @Date: 2021-07-15 09:33:39
* @LastEditTime: 2021-07-16 14:22:57
* @LastEditors: Please set LastEditors
* @Description: In User Settings Edit
* @FilePath: \evm-store\tools\frontend\src\utils\request.js
*/
import axios from "axios";
import store from "@/store";
import router from "@/router";
import { getToken } from "@/utils/auth";
// create an axios instance
......@@ -35,15 +45,14 @@ service.interceptors.request.use(
service.interceptors.response.use(
(response) => {
const res = response.data;
console.log(res)
// if (res.code === 200) return Promise.resolve(res);
// else if (res.code === 401) {
// store.dispatch("user/removeToken").then(() => {
// window.location.reload();
// });
// } else return Promise.reject(res);
if (res.code === 401) {
window.sessionStorage.removeItem("Authorization")
store.commit("frontend/login/removeToken")
router.push({ path: "/user/login" })
}
return res;
},
......
......@@ -39,8 +39,9 @@
mode="default"
style="width: 100%"
placeholder="应用所属类别"
:allowClear="true"
>
<a-select-option v-for="item in categoryList" :key="(item.value + 9).toString(36) + item.value">
<a-select-option v-for="item in categoryList" :key="item.value">
{{ item.label }}
</a-select-option>
</a-select>
......@@ -72,8 +73,8 @@
:wrapperCol="{ span: 10 }"
:required="true"
>
<a-select :default-value="sizeList[0].label" v-model="post.app_screen_size" style="width: 120px">
<a-select-option v-for="item in sizeList" :key="(item.value + 9).toString(36) + item.value" :value="item.label">
<a-select :default-value="sizeList[0].label" v-model="post.app_screen_size" :allowClear="true" style="width: 120px">
<a-select-option v-for="item in sizeList" :key="item.value" :value="item.label">
{{ item.label }}
</a-select-option>
</a-select>
......@@ -84,8 +85,8 @@
:wrapperCol="{ span: 10 }"
:required="true"
>
<a-select :default-value="portList[0].label" v-model="post.app_arch" style="width: 120px">
<a-select-option v-for="item in portList" :key="(item.value + 9).toString(36) + item.value" :value="item.label">
<a-select :default-value="portList[0].label" v-model="post.app_arch" :allowClear="true" style="width: 120px">
<a-select-option v-for="item in portList" :key="item.value" :value="item.label">
{{ item.label }}
</a-select-option>
</a-select>
......
......@@ -282,6 +282,7 @@ const columns = [
},
];
import { mapGetters } from "vuex";
export default {
name: "ApplicationManager",
data: () => ({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment