mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-19 15:48:04 +01:00
challenges: lots of misc. bug fixes
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
habitrpg.controller("ChallengesCtrl", ['$scope', 'User', 'Challenges', 'Notification', '$compile', 'Groups',
|
habitrpg.controller("ChallengesCtrl", ['$scope', 'User', 'Challenges', 'Notification', '$compile', 'Groups', '$state',
|
||||||
function($scope, User, Challenges, Notification, $compile, Groups) {
|
function($scope, User, Challenges, Notification, $compile, Groups, $state) {
|
||||||
|
|
||||||
// FIXME get this from cache
|
// FIXME get this from cache
|
||||||
Groups.Group.query(function(groups){
|
Groups.Group.query(function(groups){
|
||||||
@@ -20,7 +20,7 @@ habitrpg.controller("ChallengesCtrl", ['$scope', 'User', 'Challenges', 'Notifica
|
|||||||
* Create
|
* Create
|
||||||
*/
|
*/
|
||||||
$scope.create = function() {
|
$scope.create = function() {
|
||||||
$scope.newChallenge = new Challenges.Challenge({
|
$scope.obj = $scope.newChallenge = new Challenges.Challenge({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
habits: [],
|
habits: [],
|
||||||
@@ -40,11 +40,11 @@ habitrpg.controller("ChallengesCtrl", ['$scope', 'User', 'Challenges', 'Notifica
|
|||||||
$scope.save = function(challenge) {
|
$scope.save = function(challenge) {
|
||||||
if (!challenge.group) return alert('Please select group');
|
if (!challenge.group) return alert('Please select group');
|
||||||
var isNew = !challenge._id;
|
var isNew = !challenge._id;
|
||||||
challenge.$save(function(){
|
challenge.$save(function(_challenge){
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
Notification.text('Challenge Created');
|
Notification.text('Challenge Created');
|
||||||
$scope.discard();
|
$scope.discard();
|
||||||
Challenges.Challenge.query();
|
$scope.challenges.unshift(_challenge);
|
||||||
} else {
|
} else {
|
||||||
// TODO figure out a more elegant way about this
|
// TODO figure out a more elegant way about this
|
||||||
//challenge._editing = false;
|
//challenge._editing = false;
|
||||||
@@ -64,9 +64,13 @@ habitrpg.controller("ChallengesCtrl", ['$scope', 'User', 'Challenges', 'Notifica
|
|||||||
/**
|
/**
|
||||||
* Delete
|
* Delete
|
||||||
*/
|
*/
|
||||||
$scope["delete"] = function(challenge) {
|
$scope["delete"] = function(challenge, $index) {
|
||||||
if (confirm("Delete challenge, are you sure?") !== true) return;
|
if (confirm("Delete challenge, are you sure?") !== true) return;
|
||||||
challenge.$delete();
|
challenge.$delete(function(){
|
||||||
|
$state.go('options.challenges');
|
||||||
|
$scope.challenges = Challenges.Challenge.query();
|
||||||
|
User.log({});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
//------------------------------------------------------------
|
//------------------------------------------------------------
|
||||||
@@ -97,29 +101,43 @@ habitrpg.controller("ChallengesCtrl", ['$scope', 'User', 'Challenges', 'Notifica
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
--------------------------
|
--------------------------
|
||||||
Unsubscribe functions
|
Subscription
|
||||||
--------------------------
|
--------------------------
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$scope.unsubscribe = function(keep) {
|
$scope.join = function(challenge){
|
||||||
|
challenge.$join(function(){
|
||||||
|
User.log({});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.leave = function(keep) {
|
||||||
if (keep == 'cancel') {
|
if (keep == 'cancel') {
|
||||||
$scope.selectedChal = undefined;
|
$scope.selectedChal = undefined;
|
||||||
} else {
|
} else {
|
||||||
$scope.selectedChal.$leave({keep:keep});
|
$scope.selectedChal.$leave({keep:keep}, function(){
|
||||||
|
User.log({});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
$scope.popoverEl.popover('destroy');
|
$scope.popoverEl.popover('destroy');
|
||||||
}
|
}
|
||||||
$scope.clickUnsubscribe = function(chal, $event) {
|
|
||||||
|
/**
|
||||||
|
* Named "clickLeave" to distinguish between "actual" leave above, since this triggers the
|
||||||
|
* "are you sure?" dialog.
|
||||||
|
*/
|
||||||
|
$scope.clickLeave = function(chal, $event) {
|
||||||
$scope.selectedChal = chal;
|
$scope.selectedChal = chal;
|
||||||
$scope.popoverEl = $($event.target);
|
$scope.popoverEl = $($event.target);
|
||||||
var html = $compile(
|
var html = $compile(
|
||||||
'<a ng-controller="ChallengesCtrl" ng-click="unsubscribe(\'remove-all\')">Remove Tasks</a><br/>\n<a ng-click="unsubscribe(\'keep-all\')">Keep Tasks</a><br/>\n<a ng-click="unsubscribe(\'cancel\')">Cancel</a><br/>'
|
'<a ng-controller="ChallengesCtrl" ng-click="leave(\'remove-all\')">Remove Tasks</a><br/>\n<a ng-click="leave(\'keep-all\')">Keep Tasks</a><br/>\n<a ng-click="leave(\'cancel\')">Cancel</a><br/>'
|
||||||
)($scope);
|
)($scope);
|
||||||
$scope.popoverEl.popover('destroy').popover({
|
$scope.popoverEl.popover('destroy').popover({
|
||||||
html: true,
|
html: true,
|
||||||
placement: 'top',
|
placement: 'top',
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
title: 'Unsubscribe From Challenge And:',
|
title: 'Leave challenge and...',
|
||||||
content: html
|
content: html
|
||||||
}).popover('show');
|
}).popover('show');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,15 @@ api.list = function(req, res) {
|
|||||||
{group: 'habitrpg'}
|
{group: 'habitrpg'}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
.select('name description memberCount groups')
|
.select('name description memberCount groups members')
|
||||||
.populate('groups', '_id name')
|
.populate('groups', '_id name')
|
||||||
.exec(function(err, challenges){
|
.exec(function(err, challenges){
|
||||||
if (err) return res.json(500,{err:err});
|
if (err) return res.json(500,{err:err});
|
||||||
|
_.each(challenges, function(c){
|
||||||
|
if (~c.members.indexOf(user._id))
|
||||||
|
c._isMember = true;
|
||||||
|
c.members = [];
|
||||||
|
})
|
||||||
res.json(challenges);
|
res.json(challenges);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -43,6 +48,8 @@ api.get = function(req, res) {
|
|||||||
if(err) return res.json(500, {err:err});
|
if(err) return res.json(500, {err:err});
|
||||||
// slim down the return members' tasks to only the ones in the challenge
|
// slim down the return members' tasks to only the ones in the challenge
|
||||||
_.each(challenge.members, function(member){
|
_.each(challenge.members, function(member){
|
||||||
|
if (member._id == user._id)
|
||||||
|
challenge._isMember = true;
|
||||||
_.each(['habits', 'dailys', 'todos', 'rewards'], function(type){
|
_.each(['habits', 'dailys', 'todos', 'rewards'], function(type){
|
||||||
member[type] = _.where(member[type], function(task){
|
member[type] = _.where(member[type], function(task){
|
||||||
return task.challenge && task.challenge.id == challenge._id;
|
return task.challenge && task.challenge.id == challenge._id;
|
||||||
@@ -58,9 +65,8 @@ api.create = function(req, res){
|
|||||||
// FIXME sanitize
|
// FIXME sanitize
|
||||||
var challenge = new Challenge(req.body);
|
var challenge = new Challenge(req.body);
|
||||||
challenge.save(function(err, saved){
|
challenge.save(function(err, saved){
|
||||||
// Need to create challenge with refs (group, leader)? Or is this taken care of automatically?
|
|
||||||
// @see http://mongoosejs.com/docs/populate.html
|
|
||||||
if (err) return res.json(500, {err:err});
|
if (err) return res.json(500, {err:err});
|
||||||
|
Group.findByIdAndUpdate(saved.group, {$addToSet:{challenges:saved._id}}) // fixme error-check
|
||||||
res.json(saved);
|
res.json(saved);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,19 +124,32 @@ api.update = function(req, res){
|
|||||||
|
|
||||||
// DELETE
|
// DELETE
|
||||||
api['delete'] = function(req, res){
|
api['delete'] = function(req, res){
|
||||||
Challenge.findOneAndRemove({_id:req.params.cid}, function(err, removed){
|
var removed;
|
||||||
if (err) return res.json(500, {err: err});
|
async.waterfall([
|
||||||
User.find({_id:{$in: removed.members}}, function(err, users){
|
function(cb){
|
||||||
if (err) throw err;
|
Challenge.findOneAndRemove({_id:req.params.cid}, cb)
|
||||||
|
},
|
||||||
|
function(_removed, cb) {
|
||||||
|
removed = _removed;
|
||||||
|
User.find({_id:{$in: removed.members}}, cb);
|
||||||
|
},
|
||||||
|
function(users, cb) {
|
||||||
|
var parallel = [];
|
||||||
_.each(users, function(user){
|
_.each(users, function(user){
|
||||||
_.each(user.tasks, function(task){
|
_.each(user.tasks, function(task){
|
||||||
if (task.challenge && task.challenge.id == removed._id) {
|
if (task.challenge && task.challenge.id == removed._id) {
|
||||||
task.challenge.broken = 'CHALLENGE_DELETED';
|
task.challenge.broken = 'CHALLENGE_DELETED';
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
user.save();
|
parallel.push(function(cb2){
|
||||||
|
user.save(cb2);
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
async.parallel(parallel, cb);
|
||||||
|
}
|
||||||
|
], function(err){
|
||||||
|
if (err) return res.json(500, {err: err});
|
||||||
|
res.send(200);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +218,7 @@ api.join = function(req, res){
|
|||||||
}
|
}
|
||||||
], function(err, result){
|
], function(err, result){
|
||||||
if(err) return res.json(500,{err:err});
|
if(err) return res.json(500,{err:err});
|
||||||
|
result._isMember = true;
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -206,7 +226,7 @@ api.join = function(req, res){
|
|||||||
function unlink(user, cid, keep, tid) {
|
function unlink(user, cid, keep, tid) {
|
||||||
switch (keep) {
|
switch (keep) {
|
||||||
case 'keep':
|
case 'keep':
|
||||||
delete user.tasks[tid].challenge;
|
user.tasks[tid].challenge = {};
|
||||||
break;
|
break;
|
||||||
case 'remove':
|
case 'remove':
|
||||||
user[user.tasks[tid].type+'s'].id(tid).remove();
|
user[user.tasks[tid].type+'s'].id(tid).remove();
|
||||||
@@ -214,7 +234,7 @@ function unlink(user, cid, keep, tid) {
|
|||||||
case 'keep-all':
|
case 'keep-all':
|
||||||
_.each(user.tasks, function(t){
|
_.each(user.tasks, function(t){
|
||||||
if (t.challenge && t.challenge.id == cid) {
|
if (t.challenge && t.challenge.id == cid) {
|
||||||
delete t.challenge;
|
t.challenge = {};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -249,6 +269,7 @@ api.leave = function(req, res){
|
|||||||
}
|
}
|
||||||
], function(err, result){
|
], function(err, result){
|
||||||
if(err) return res.json(500,{err:err});
|
if(err) return res.json(500,{err:err});
|
||||||
|
result._isMember = false;
|
||||||
res.json(result);
|
res.json(result);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -264,6 +285,10 @@ api.unlink = function(req, res, next) {
|
|||||||
if (!req.query.keep)
|
if (!req.query.keep)
|
||||||
return res.json(400, {err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'});
|
return res.json(400, {err: 'Provide unlink method as ?keep=keep-all (keep, keep-all, remove, remove-all)'});
|
||||||
unlink(user, cid, req.query.keep, tid);
|
unlink(user, cid, req.query.keep, tid);
|
||||||
|
user.markModified('habits');
|
||||||
|
user.markModified('dailys');
|
||||||
|
user.markModified('todos');
|
||||||
|
user.markModified('rewards');
|
||||||
user.save(function(err, saved){
|
user.save(function(err, saved){
|
||||||
if (err) return res.json(500,{err:err});
|
if (err) return res.json(500,{err:err});
|
||||||
res.send(200);
|
res.send(200);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ var Schema = mongoose.Schema;
|
|||||||
var helpers = require('habitrpg-shared/script/helpers');
|
var helpers = require('habitrpg-shared/script/helpers');
|
||||||
var _ = require('lodash');
|
var _ = require('lodash');
|
||||||
var TaskSchema = require('./task').schema;
|
var TaskSchema = require('./task').schema;
|
||||||
|
var Group = require('./group').model;
|
||||||
|
|
||||||
var ChallengeSchema = new Schema({
|
var ChallengeSchema = new Schema({
|
||||||
_id: {type: String, 'default': helpers.uuid},
|
_id: {type: String, 'default': helpers.uuid},
|
||||||
@@ -20,7 +21,7 @@ var ChallengeSchema = new Schema({
|
|||||||
//},
|
//},
|
||||||
timestamp: {type: Date, 'default': Date.now},
|
timestamp: {type: Date, 'default': Date.now},
|
||||||
members: [{type: String, ref: 'User'}],
|
members: [{type: String, ref: 'User'}],
|
||||||
memberCount: [{type: Number, 'default': 0}]
|
memberCount: {type: Number, 'default': 0}
|
||||||
});
|
});
|
||||||
|
|
||||||
ChallengeSchema.virtual('tasks').get(function () {
|
ChallengeSchema.virtual('tasks').get(function () {
|
||||||
@@ -29,10 +30,19 @@ ChallengeSchema.virtual('tasks').get(function () {
|
|||||||
return tasks;
|
return tasks;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate()
|
||||||
|
// @see https://github.com/LearnBoost/mongoose/issues/964
|
||||||
ChallengeSchema.pre('save', function(next){
|
ChallengeSchema.pre('save', function(next){
|
||||||
this.memberCount = _.size(this.members);
|
this.memberCount = _.size(this.members);
|
||||||
next();
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ChallengeSchema.methods.toJSON = function(){
|
||||||
|
var doc = this.toObject();
|
||||||
|
doc.memberCount = _.size(doc.members); // @see pre('save') comment above
|
||||||
|
doc._isMember = this._isMember;
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.schema = ChallengeSchema;
|
module.exports.schema = ChallengeSchema;
|
||||||
module.exports.model = mongoose.model("Challenge", ChallengeSchema);
|
module.exports.model = mongoose.model("Challenge", ChallengeSchema);
|
||||||
@@ -58,6 +58,8 @@ function removeDuplicates(doc){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME this isn't always triggered, since we sometimes use update() or findByIdAndUpdate()
|
||||||
|
// @see https://github.com/LearnBoost/mongoose/issues/964
|
||||||
GroupSchema.pre('save', function(next){
|
GroupSchema.pre('save', function(next){
|
||||||
removeDuplicates(this);
|
removeDuplicates(this);
|
||||||
this.memberCount = _.size(this.members);
|
this.memberCount = _.size(this.members);
|
||||||
@@ -69,6 +71,11 @@ GroupSchema.methods.toJSON = function(){
|
|||||||
var doc = this.toObject();
|
var doc = this.toObject();
|
||||||
removeDuplicates(doc);
|
removeDuplicates(doc);
|
||||||
doc._isMember = this._isMember;
|
doc._isMember = this._isMember;
|
||||||
|
|
||||||
|
// @see pre('save') comment above
|
||||||
|
this.memberCount = _.size(this.members);
|
||||||
|
this.challengeCount = _.size(this.challenges);
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ script(type='text/ng-template', id='partials/options.challenges.detail.html')
|
|||||||
button.btn.btn-default(ng-click='challenge._locked = false') Edit
|
button.btn.btn-default(ng-click='challenge._locked = false') Edit
|
||||||
li(ng-hide='challenge._locked')
|
li(ng-hide='challenge._locked')
|
||||||
button.btn.btn-primary(ng-click='save(challenge)') Save
|
button.btn.btn-primary(ng-click='save(challenge)') Save
|
||||||
button.btn.btn-danger(ng-click='delete(challenge)') Delete
|
button.btn.btn-danger(ng-click='delete(challenge, $index)') Delete
|
||||||
button.btn.btn-default(ng-click='challenge._locked=true') Cancel
|
button.btn.btn-default(ng-click='challenge._locked=true') Cancel
|
||||||
|
|
||||||
div(ng-hide='challenge._locked')
|
div(ng-hide='challenge._locked')
|
||||||
@@ -32,13 +32,14 @@ script(type='text/ng-template', id='partials/options.challenges.html')
|
|||||||
| {{group.name}}
|
| {{group.name}}
|
||||||
li
|
li
|
||||||
input(type='checkbox', ng-model='search.members')
|
input(type='checkbox', ng-model='search.members')
|
||||||
| Subscribed (TODO)
|
| Particiapting in(TODO)
|
||||||
li
|
li
|
||||||
input(type='checkbox', ng-model='search.members')
|
input(type='checkbox', ng-model='search.members')
|
||||||
| Available (TODO)
|
| Not participating in (TODO)
|
||||||
.span10
|
.span10
|
||||||
// Creation form
|
// Creation form
|
||||||
a.btn.btn-success(ng-click='create()') Create Challenge
|
div(ng-hide='newChallenge')
|
||||||
|
button.btn.btn-success(ng-click='create()') Create Challenge
|
||||||
.create-challenge-from(ng-if='newChallenge')
|
.create-challenge-from(ng-if='newChallenge')
|
||||||
form(ng-submit='save(newChallenge)')
|
form(ng-submit='save(newChallenge)')
|
||||||
div
|
div
|
||||||
@@ -54,7 +55,7 @@ script(type='text/ng-template', id='partials/options.challenges.html')
|
|||||||
.accordion-group(ng-repeat='challenge in challenges | filter:search', ng-init='challenge._locked=true')
|
.accordion-group(ng-repeat='challenge in challenges | filter:search', ng-init='challenge._locked=true')
|
||||||
.accordion-heading
|
.accordion-heading
|
||||||
ul.pull-right.challenge-accordion-header-specs
|
ul.pull-right.challenge-accordion-header-specs
|
||||||
li {{challenge.members.length}} Subscribers
|
li {{challenge.memberCount}} Participants
|
||||||
li(ng-show='challenge.prize')
|
li(ng-show='challenge.prize')
|
||||||
// prize
|
// prize
|
||||||
table(ng-show='challenge.prize')
|
table(ng-show='challenge.prize')
|
||||||
@@ -64,13 +65,13 @@ script(type='text/ng-template', id='partials/options.challenges.html')
|
|||||||
span.Pet_Currency_Gem1x
|
span.Pet_Currency_Gem1x
|
||||||
td Prize
|
td Prize
|
||||||
li
|
li
|
||||||
// subscribe / unsubscribe
|
// leave / join
|
||||||
a.btn.btn-small.btn-danger(ng-show='indexOf(challenge.members, user._id)', ng-click='clickUnsubscribe(challenge, $event)')
|
a.btn.btn-small.btn-danger(ng-show='challenge._isMember', ng-click='clickLeave(challenge, $event)')
|
||||||
i.icon-ban-circle
|
i.icon-ban-circle
|
||||||
| Unsubscribe
|
| Leave
|
||||||
a.btn.btn-small.btn-success(ng-hide='indexOf(challenge.members, user._id)', ng-click='challenge.$join()')
|
a.btn.btn-small.btn-success(ng-hide='challenge._isMember', ng-click='join(challenge)')
|
||||||
i.icon-ok
|
i.icon-ok
|
||||||
| Subscribe
|
| Join
|
||||||
a.accordion-toggle(ui-sref='options.challenges.detail({cid:challenge._id})') {{challenge.name}} (by {{challenge.leader.name}})
|
a.accordion-toggle(ui-sref='options.challenges.detail({cid:challenge._id})') {{challenge.name}} (by {{challenge.leader.name}})
|
||||||
.accordion-body(ng-class='{collapse: !$stateParams.cid == challenge._id}')
|
.accordion-body(ng-class='{collapse: !$stateParams.cid == challenge._id}')
|
||||||
.accordion-inner(ng-if='$stateParams.cid == challenge._id')
|
.accordion-inner(ng-if='$stateParams.cid == challenge._id')
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ li(ng-repeat='task in obj[list.type+"s"]', class='task {{taskClasses(task, user.
|
|||||||
//challenges
|
//challenges
|
||||||
span(ng-if='task.challenge.id')
|
span(ng-if='task.challenge.id')
|
||||||
span(ng-if='task.challenge.broken')
|
span(ng-if='task.challenge.broken')
|
||||||
i.icon-bullhorn(style='background-color:red;', ng-click='task._edit=true', tooltip="Broken Challenge Link")
|
i.icon-bullhorn(style='background-color:red;', ng-click='task._editing = true', tooltip="Broken Challenge Link", tooltip-placement='right')
|
||||||
span(ng-if='!task.challenge.broken')
|
span(ng-if='!task.challenge.broken')
|
||||||
i.icon-bullhorn(tooltip="Challenge Task")
|
i.icon-bullhorn(tooltip="Challenge")
|
||||||
// delete
|
// delete
|
||||||
a(ng-if='!task.challenge.id', ng-click='removeTask(obj[list.type+"s"], $index)', tooltip='Delete')
|
a(ng-if='!task.challenge.id', ng-click='removeTask(obj[list.type+"s"], $index)', tooltip='Delete')
|
||||||
i.icon-trash
|
i.icon-trash
|
||||||
@@ -68,7 +68,7 @@ li(ng-repeat='task in obj[list.type+"s"]', class='task {{taskClasses(task, user.
|
|||||||
p
|
p
|
||||||
a(ng-click='unlink(task, "keep")') Keep It
|
a(ng-click='unlink(task, "keep")') Keep It
|
||||||
| |
|
| |
|
||||||
a(ng-click="remove(list, $index)") Remove It
|
a(ng-click="removeTask(obj[list.type+'s'], $index)") Remove It
|
||||||
div(ng-if='task.challenge.broken=="CHALLENGE_DELETED"')
|
div(ng-if='task.challenge.broken=="CHALLENGE_DELETED"')
|
||||||
p Broken Challenge Link: this task was part of a challenge, but the challenge (or group) has been deleted. What to do with the orphan tasks?
|
p Broken Challenge Link: this task was part of a challenge, but the challenge (or group) has been deleted. What to do with the orphan tasks?
|
||||||
p
|
p
|
||||||
|
|||||||
Reference in New Issue
Block a user